Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a44886ef | |||
| f422d4158c | |||
| 70ec060b7a | |||
| 3b41a45757 | |||
| 19e1ca505d | |||
| f75c40b417 | |||
| 649c0f124b | |||
| 6026c24450 | |||
| ae5c61a179 | |||
| 7657f48f1e | |||
| 61e62a6904 | |||
| 9a252c8dde | |||
| 8db6b4d230 | |||
| d5e6a8f6da | |||
| 06cd3493fd | |||
| 6a8f0e9143 | |||
| 6f8c2201ed | |||
| ec49e24c8d | |||
| cac62c6caf | |||
| 619f069358 | |||
| ddab0db781 | |||
| 443b11f287 | |||
| 3c9e473823 | |||
| 3ac32dc3bc | |||
| 29ef17f4f3 | |||
| fc82f7a0cb | |||
| b743754ec2 | |||
| c4ffe418b5 | |||
| 6cf880506d | |||
| aca34e2dfb | |||
| c07a0f0f7e | |||
| 776e5edb68 | |||
| 8b0dd732f7 | |||
| f12d29563a | |||
| a4995606e5 | |||
| 0a8a755909 | |||
| 719de12a6c | |||
| 72018aa389 | |||
| 1e73ea04f1 | |||
| 72708475a3 | |||
| e07d8436b7 | |||
| 1711ee6a1f | |||
| 369cf0a7c9 | |||
| 3f2813d16f | |||
| fddac2aa2f | |||
| 1261e93ede | |||
| b6165e56e5 | |||
| 5e281c534a | |||
| 9082281225 | |||
| 058d6089b1 | |||
| ba0cb07c91 | |||
| 4228e9a384 | |||
| 035ec0c0dc | |||
| f0c93ffa3b | |||
| ad4fe3d783 | |||
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 | |||
| 9f3b3450fa | |||
| ba90ad8132 | |||
| 9157740069 |
+27
-18
@@ -1,8 +1,12 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to any branch: typecheck + lint + test
|
||||
# Push to dev: gates + build :dev + :<sha>
|
||||
# Tag v* (release): gates + build :latest + :<sha> + :<version>
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -11,6 +15,13 @@
|
||||
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
@@ -18,7 +29,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
branches: [dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
@@ -51,23 +62,20 @@ env:
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Cache node_modules directly (not the npm download cache).
|
||||
# npm ci still has to extract + link every module even with a
|
||||
# warm download cache, which is where the real time goes. Caching
|
||||
# the output directory lets us skip npm ci entirely on hits.
|
||||
- name: Cache node_modules
|
||||
id: npm-cache
|
||||
- name: Cache npm download cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
path: ~/.npm
|
||||
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: npm-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.npm-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
@@ -77,6 +85,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -88,6 +97,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -118,11 +128,10 @@ jobs:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run the gates for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production image.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ci-runner
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Link rss_items to their discussion-summary note.
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-04-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_rss_items_discussion_note_id",
|
||||
"rss_items",
|
||||
"notes",
|
||||
["discussion_note_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("rss_items", "discussion_note_id")
|
||||
@@ -313,7 +313,7 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
|
||||
|
||||
### Tool Routing
|
||||
|
||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. A thinking-mode heuristic (`_should_think()`) detects complex prompts and enables extended reasoning.
|
||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
|
||||
|
||||
### Tool Loop
|
||||
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
# Unified Lookup Tool & Wikipedia Integration — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `search_web` with a unified `lookup` tool (Wikipedia-first, SearXNG fallback) and add Wikipedia as a source in the research pipeline.
|
||||
|
||||
**Architecture:** New `wikipedia.py` service with `wiki_summary` and `wiki_search`. `lookup` tool in `web.py` replaces `search_web`. Research pipeline in `research.py` gains Wikipedia sources alongside SearXNG. All `search_web` references across the codebase are updated.
|
||||
|
||||
**Tech Stack:** Python 3.12, httpx, pytest, asyncio
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Wikipedia Service Module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fabledassistant/services/wikipedia.py`
|
||||
- Create: `tests/test_wikipedia.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `wiki_summary`**
|
||||
|
||||
```python
|
||||
# tests/test_wikipedia.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_extract():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"type": "standard",
|
||||
"title": "Python (programming language)",
|
||||
"extract": "Python is a high-level programming language.",
|
||||
"content_urls": {
|
||||
"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}
|
||||
},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python programming language")
|
||||
|
||||
assert result is not None
|
||||
assert result["title"] == "Python (programming language)"
|
||||
assert "high-level" in result["extract"]
|
||||
assert "wikipedia.org" in result["url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_404():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
|
||||
))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("xyznonexistenttopic123")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"type": "disambiguation",
|
||||
"title": "Python",
|
||||
"extract": "Python may refer to...",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python")
|
||||
|
||||
assert result is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'fabledassistant.services.wikipedia'`
|
||||
|
||||
- [ ] **Step 3: Implement `wiki_summary`**
|
||||
|
||||
```python
|
||||
# src/fabledassistant/services/wikipedia.py
|
||||
"""Wikipedia API: lightweight topic lookups and article search."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
|
||||
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
||||
_TIMEOUT = 5.0
|
||||
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
|
||||
|
||||
|
||||
async def wiki_summary(query: str) -> dict | None:
|
||||
"""Look up a topic by title via the Wikipedia REST summary endpoint.
|
||||
|
||||
Returns {"title", "extract", "url"} on hit, None on miss.
|
||||
"""
|
||||
encoded = url_quote(query.replace(" ", "_"), safe="")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
||||
) as client:
|
||||
resp = await client.get(f"{_SUMMARY_URL}/{encoded}", follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.debug("Wikipedia summary lookup failed for %r", query, exc_info=True)
|
||||
return None
|
||||
|
||||
if data.get("type") == "disambiguation":
|
||||
return None
|
||||
|
||||
extract = data.get("extract", "").strip()
|
||||
if not extract:
|
||||
return None
|
||||
|
||||
url = (
|
||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
||||
)
|
||||
return {"title": data.get("title", query), "extract": extract, "url": url}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: 3 passed
|
||||
|
||||
- [ ] **Step 5: Write failing tests for `wiki_search`**
|
||||
|
||||
Add to `tests/test_wikipedia.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_results():
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
|
||||
search_response = MagicMock()
|
||||
search_response.status_code = 200
|
||||
search_response.json.return_value = {
|
||||
"query": {
|
||||
"search": [
|
||||
{"title": "QUIC"},
|
||||
{"title": "HTTP/3"},
|
||||
]
|
||||
}
|
||||
}
|
||||
search_response.raise_for_status = MagicMock()
|
||||
|
||||
summary_response = MagicMock()
|
||||
summary_response.status_code = 200
|
||||
summary_response.json.return_value = {
|
||||
"type": "standard",
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/QUIC"}},
|
||||
}
|
||||
summary_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=[search_response, summary_response, summary_response])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("QUIC protocol", limit=2)
|
||||
|
||||
assert len(results) >= 1
|
||||
assert results[0]["title"] == "QUIC"
|
||||
assert "transport" in results[0]["extract"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_empty_on_failure():
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection failed"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("anything")
|
||||
|
||||
assert results == []
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement `wiki_search`**
|
||||
|
||||
Add to `src/fabledassistant/services/wikipedia.py`:
|
||||
|
||||
```python
|
||||
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
|
||||
"""Search Wikipedia for articles matching a query.
|
||||
|
||||
Returns [{"title", "extract", "url"}, ...] (may be empty).
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
||||
) as client:
|
||||
resp = await client.get(_SEARCH_URL, params={
|
||||
"action": "query",
|
||||
"list": "search",
|
||||
"srsearch": query,
|
||||
"srlimit": str(limit),
|
||||
"format": "json",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
hits = resp.json().get("query", {}).get("search", [])
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for hit in hits:
|
||||
title = hit.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
encoded = url_quote(title.replace(" ", "_"), safe="")
|
||||
try:
|
||||
summary_resp = await client.get(
|
||||
f"{_SUMMARY_URL}/{encoded}", follow_redirects=True,
|
||||
)
|
||||
summary_resp.raise_for_status()
|
||||
data = summary_resp.json()
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("type") == "disambiguation":
|
||||
continue
|
||||
extract = data.get("extract", "").strip()
|
||||
if not extract:
|
||||
continue
|
||||
url = (
|
||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
||||
)
|
||||
results.append({"title": data.get("title", title), "extract": extract, "url": url})
|
||||
return results
|
||||
except Exception:
|
||||
logger.debug("Wikipedia search failed for %r", query, exc_info=True)
|
||||
return []
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run all wikipedia tests**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: 5 passed
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/wikipedia.py tests/test_wikipedia.py
|
||||
git commit -m "feat: add wikipedia service with summary lookup and search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Lookup Tool (replaces search_web)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools/web.py`
|
||||
- Create: `tests/test_lookup_tool.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `lookup`**
|
||||
|
||||
```python
|
||||
# tests/test_lookup_tool.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_hit():
|
||||
"""lookup returns wikipedia source when wiki_summary succeeds."""
|
||||
wiki_data = {
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
||||
}
|
||||
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=wiki_data):
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["source"] == "wikipedia"
|
||||
assert result["data"]["title"] == "QUIC"
|
||||
assert "transport" in result["data"]["extract"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
||||
"""lookup falls back to SearXNG + article fetch when Wikipedia misses."""
|
||||
searxng_results = [
|
||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.services.tools.web.Config") as mock_config, \
|
||||
patch("fabledassistant.services.tools.web._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.tools.web._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["source"] == "web"
|
||||
assert result["data"]["results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_no_searxng():
|
||||
"""lookup returns no-results when Wikipedia misses and SearXNG is not configured."""
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.services.tools.web.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["source"] == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_always_available():
|
||||
"""lookup tool must appear in get_tools_for_user regardless of SearXNG config."""
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
assert "lookup" in tool_names
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
||||
Expected: FAIL (no `lookup_tool` function)
|
||||
|
||||
- [ ] **Step 3: Replace `search_web` with `lookup` in `web.py`**
|
||||
|
||||
Replace the `search_web_tool` function (lines 12–36 of `src/fabledassistant/services/tools/web.py`) with:
|
||||
|
||||
```python
|
||||
@tool(
|
||||
name="lookup",
|
||||
description=(
|
||||
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
||||
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
||||
"'how does Y work', current events, or version numbers. No note is saved. "
|
||||
"For comprehensive written reports saved as notes, use research_topic instead."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
async def lookup_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
query = arguments.get("query", "")
|
||||
|
||||
# 1. Try Wikipedia first
|
||||
wiki = await wiki_summary(query)
|
||||
if wiki:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "wikipedia",
|
||||
"data": wiki,
|
||||
}
|
||||
|
||||
# 2. Fall back to SearXNG + article fetch
|
||||
if Config.searxng_enabled():
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
results = await _search_searxng(query)
|
||||
if results:
|
||||
articles: list[dict] = []
|
||||
for r in results[:2]:
|
||||
url = r.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
content = await _fetch_full_article(url)
|
||||
articles.append({
|
||||
"url": url,
|
||||
"title": r.get("title", url),
|
||||
"snippet": r.get("snippet", ""),
|
||||
"content": (content or "")[:4000],
|
||||
})
|
||||
if articles:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "web",
|
||||
"data": {"query": query, "results": articles},
|
||||
}
|
||||
|
||||
# 3. No sources available
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "none",
|
||||
"data": {
|
||||
"query": query,
|
||||
"message": "No results found. You can answer from your own knowledge.",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run lookup tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
||||
Expected: 4 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools/web.py tests/test_lookup_tool.py
|
||||
git commit -m "feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update All `search_web` References
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools/web.py:45` (research_topic description)
|
||||
- Modify: `src/fabledassistant/services/tools/web.py:67` (search_images description)
|
||||
- Modify: `src/fabledassistant/services/tools/rss.py:75` (read_article description)
|
||||
- Modify: `src/fabledassistant/services/generation_task.py:133` (status label map)
|
||||
- Modify: `src/fabledassistant/services/llm.py:608` (action list)
|
||||
|
||||
- [ ] **Step 1: Update `research_topic` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/web.py`, change the `research_topic` description from:
|
||||
|
||||
```python
|
||||
"For a quick factual answer without saving a note, use search_web."
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"For a quick factual answer without saving a note, use lookup."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `search_images` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/web.py`, change the `search_images` description from:
|
||||
|
||||
```python
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `read_article` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/rss.py`, change:
|
||||
|
||||
```python
|
||||
"Do NOT use search_web for URLs — use this tool instead."
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"Do NOT use lookup for URLs — use this tool instead."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update status label map in `generation_task.py`**
|
||||
|
||||
In `src/fabledassistant/services/generation_task.py`, line 133, change:
|
||||
|
||||
```python
|
||||
"search_web": "Searching the web",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"lookup": "Looking up information",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update action list in `llm.py`**
|
||||
|
||||
In `src/fabledassistant/services/llm.py`, line 608, change:
|
||||
|
||||
```python
|
||||
actions.extend(["search_web", "research_topic", "search_images"])
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
actions.extend(["lookup", "research_topic", "search_images"])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite to check for regressions**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass (no test references `search_web` by name in assertions)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools/web.py src/fabledassistant/services/tools/rss.py src/fabledassistant/services/generation_task.py src/fabledassistant/services/llm.py
|
||||
git commit -m "refactor: update all search_web references to lookup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Wikipedia Sources to Research Pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/research.py`
|
||||
- Modify: `tests/test_research_pipeline.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for Wikipedia in research pipeline**
|
||||
|
||||
Add to `tests/test_research_pipeline.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_wikipedia_sources():
|
||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
]
|
||||
|
||||
note_id_counter = iter(range(30, 40))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
# The sources passed to _generate_outline should include the Wikipedia article
|
||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
||||
source_urls = [s["url"] for s in sources_arg]
|
||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
||||
Expected: FAIL (no `wiki_search` import in research.py)
|
||||
|
||||
- [ ] **Step 3: Add Wikipedia sources to the research pipeline**
|
||||
|
||||
In `src/fabledassistant/services/research.py`, add the import at the top (after existing imports):
|
||||
|
||||
```python
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
```
|
||||
|
||||
Then modify Step 2 (the parallel search section, around lines 208–246). Replace:
|
||||
|
||||
```python
|
||||
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
|
||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||
if i > 0:
|
||||
await asyncio.sleep(0.2 * i)
|
||||
_status(f"Searching: {query}...")
|
||||
results = await _search_searxng(query)
|
||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||
return query, results
|
||||
|
||||
search_results = await asyncio.gather(
|
||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
||||
)
|
||||
|
||||
# Deduplicate URLs across all queries
|
||||
seen_urls: set[str] = set()
|
||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
||||
for query, results in search_results:
|
||||
for result in results[:PAGES_PER_QUERY]:
|
||||
url = result.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
url_tasks.append((url, result, query))
|
||||
|
||||
# Fetch all unique URLs in parallel
|
||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||
title = result.get("title", url)
|
||||
_status(f"Reading: {title[:60]}...")
|
||||
content = await fetch_url_content(url)
|
||||
return {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"query": query,
|
||||
"snippet": result.get("snippet", ""),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
all_sources: list[dict] = list(await asyncio.gather(
|
||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||
))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
|
||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||
if i > 0:
|
||||
await asyncio.sleep(0.2 * i)
|
||||
_status(f"Searching: {query}...")
|
||||
results = await _search_searxng(query)
|
||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||
return query, results
|
||||
|
||||
async def _wiki_for_query(query: str) -> list[dict]:
|
||||
return await wiki_search(query, limit=1)
|
||||
|
||||
searxng_task = asyncio.gather(
|
||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
||||
)
|
||||
wiki_task = asyncio.gather(
|
||||
*[_wiki_for_query(q) for q in queries]
|
||||
)
|
||||
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
|
||||
|
||||
# Deduplicate URLs across all queries
|
||||
seen_urls: set[str] = set()
|
||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
||||
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
|
||||
|
||||
for query, results in search_results:
|
||||
for result in results[:PAGES_PER_QUERY]:
|
||||
url = result.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
url_tasks.append((url, result, query))
|
||||
|
||||
# Add Wikipedia results (they already have content via extract)
|
||||
for query, wiki_hits in zip(queries, wiki_results):
|
||||
for hit in wiki_hits:
|
||||
url = hit.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
wiki_sources.append({
|
||||
"url": url,
|
||||
"title": hit["title"],
|
||||
"query": query,
|
||||
"snippet": hit["extract"][:200],
|
||||
"content": hit["extract"],
|
||||
})
|
||||
|
||||
# Fetch all unique SearXNG URLs in parallel
|
||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||
title = result.get("title", url)
|
||||
_status(f"Reading: {title[:60]}...")
|
||||
content = await fetch_url_content(url)
|
||||
return {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"query": query,
|
||||
"snippet": result.get("snippet", ""),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
fetched_sources: list[dict] = list(await asyncio.gather(
|
||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||
))
|
||||
|
||||
all_sources = wiki_sources + fetched_sources
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the full research test suite for regressions**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/research.py tests/test_research_pipeline.py
|
||||
git commit -m "feat: add Wikipedia as research pipeline source alongside SearXNG"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Lint, Typecheck, Final Verification
|
||||
|
||||
**Files:**
|
||||
- All modified files
|
||||
|
||||
- [ ] **Step 1: Run ruff lint**
|
||||
|
||||
Run: `uv run ruff check src/fabledassistant/services/wikipedia.py src/fabledassistant/services/tools/web.py src/fabledassistant/services/research.py tests/test_wikipedia.py tests/test_lookup_tool.py`
|
||||
Expected: All checks passed (fix any issues if not)
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `cd frontend && npx vue-tsc --noEmit` (frontend unchanged, but verify nothing broke)
|
||||
Expected: Clean
|
||||
|
||||
- [ ] **Step 3: Run full test suite one last time**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Commit any lint fixes if needed**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "fix: lint cleanup for lookup/wikipedia changes"
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Unified Lookup Tool & Wikipedia Integration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the fragmented `search_web` tool with a single `lookup` tool that checks Wikipedia first and falls back to SearXNG web search. Add Wikipedia as an additional source in the research pipeline. Result: one lightweight tool for factual questions (always available, no config required), and richer research output.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two changes to the search/knowledge stack:
|
||||
|
||||
1. **New `lookup` tool** replaces `search_web`. Tries Wikipedia REST API summary endpoint first (~200ms, reliable, no config). Falls back to SearXNG + trafilatura article fetch when Wikipedia misses and SearXNG is configured. Always available (no `requires` field).
|
||||
|
||||
2. **Wikipedia sources in research pipeline.** During sub-query execution, `wiki_search` runs alongside `_search_searxng`. Wikipedia articles merge into the source pool and get deduplicated by URL.
|
||||
|
||||
Shared Wikipedia logic lives in a new `wikipedia.py` service module.
|
||||
|
||||
## Components
|
||||
|
||||
### `src/fabledassistant/services/wikipedia.py` (new)
|
||||
|
||||
Two async functions:
|
||||
|
||||
**`wiki_summary(query: str) -> dict | None`**
|
||||
- Direct title lookup via `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`
|
||||
- Returns `{"title": str, "extract": str, "url": str}` on hit
|
||||
- Returns `None` on 404, disambiguation pages (`"type": "disambiguation"`), network errors, or empty extracts
|
||||
- 5-second timeout
|
||||
- User-Agent: `"FabledAssistant/1.0 (https://fabledsword.com)"`
|
||||
|
||||
**`wiki_search(query: str, limit: int = 3) -> list[dict]`**
|
||||
- Search via `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&srlimit={limit}&format=json`
|
||||
- For each search result, fetch its summary via the summary endpoint to get the extract
|
||||
- Returns `[{"title": str, "extract": str, "url": str}, ...]`
|
||||
- Returns `[]` on any failure
|
||||
- Same timeout and User-Agent as above
|
||||
|
||||
### `src/fabledassistant/services/tools/web.py` (modified)
|
||||
|
||||
**Remove:** `search_web_tool`
|
||||
|
||||
**Add:** `lookup_tool`
|
||||
|
||||
```
|
||||
@tool(
|
||||
name="lookup",
|
||||
description="Look up a topic, concept, or factual question. Returns a concise
|
||||
answer from Wikipedia or web sources. Use for definitions,
|
||||
explanations, 'what is X', 'how does Y work'. For comprehensive
|
||||
written reports saved as notes, use research_topic instead.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
```
|
||||
|
||||
No `requires` field — always available.
|
||||
|
||||
**Logic:**
|
||||
1. Call `wiki_summary(query)`
|
||||
2. If Wikipedia returns a result: return `{"success": True, "type": "lookup", "source": "wikipedia", "data": {"title": ..., "extract": ..., "url": ...}}`
|
||||
3. If Wikipedia misses and `Config.searxng_enabled()`:
|
||||
- Call `_search_searxng(query)` to get search results
|
||||
- Fetch top 1-2 result URLs via `_fetch_full_article` (from `rss.py`, trafilatura-based)
|
||||
- Return `{"success": True, "type": "lookup", "source": "web", "data": {"query": ..., "results": [...], "content": ...}}`
|
||||
4. If Wikipedia misses and no SearXNG: return `{"success": True, "type": "lookup", "source": "none", "data": {"query": ..., "message": "No results found. You can answer from your own knowledge."}}`
|
||||
|
||||
### `src/fabledassistant/services/research.py` (modified)
|
||||
|
||||
**In Step 2 (parallel search):**
|
||||
- For each sub-query, run `wiki_search(query, limit=1)` concurrently with `_search_searxng(query)`
|
||||
- Merge Wikipedia results into the per-query result list
|
||||
|
||||
**In Step 3 (deduplication):**
|
||||
- When deduplicating URLs, Wikipedia URLs (`wikipedia.org`) are checked against SearXNG results
|
||||
- If a Wikipedia article URL already appears in SearXNG results, skip the duplicate
|
||||
|
||||
**Wikipedia article content for synthesis:**
|
||||
- The `extract` from `wiki_search` is used as the source content (no additional fetch needed, unlike SearXNG URLs which require `fetch_url_content`)
|
||||
- This means Wikipedia sources are available immediately without an HTTP fetch step
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All Wikipedia API failures (network, timeout, malformed JSON) return `None`/`[]` silently
|
||||
- `lookup` never raises — always returns a response the model can work with
|
||||
- In the research pipeline, Wikipedia is purely additive; its failure never degrades existing SearXNG-based research
|
||||
- Disambiguation pages are detected via `"type": "disambiguation"` in the summary response and treated as a miss
|
||||
|
||||
## Testing
|
||||
|
||||
### `tests/test_wikipedia.py` (new)
|
||||
|
||||
- `test_wiki_summary_returns_extract` — mock successful summary response, verify return shape
|
||||
- `test_wiki_summary_returns_none_on_404` — mock 404, verify `None`
|
||||
- `test_wiki_summary_returns_none_on_disambiguation` — mock disambiguation response, verify `None`
|
||||
- `test_wiki_search_returns_results` — mock search API + summary fetches, verify list
|
||||
- `test_wiki_search_returns_empty_on_failure` — mock network error, verify `[]`
|
||||
|
||||
### `tests/test_lookup_tool.py` (new)
|
||||
|
||||
- `test_lookup_wikipedia_hit` — mock `wiki_summary` returning data, verify tool returns wikipedia source
|
||||
- `test_lookup_wikipedia_miss_searxng_fallback` — mock `wiki_summary` returning None, SearXNG returning results + article fetch, verify web source
|
||||
- `test_lookup_wikipedia_miss_no_searxng` — mock both missing, verify graceful "no results" response
|
||||
- `test_lookup_always_available` — verify the tool appears in `get_tools_for_user` regardless of SearXNG config
|
||||
|
||||
### `tests/test_research_pipeline.py` (add to existing)
|
||||
|
||||
- `test_research_includes_wikipedia_sources` — mock `wiki_search` alongside SearXNG, verify Wikipedia results appear in source pool
|
||||
|
||||
All tests mock HTTP calls — no live API hits.
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
- `read_article` tool — stays as-is (explicit URL fetch, different purpose)
|
||||
- `research_topic` tool definition — stays as-is (same name, description, parameters)
|
||||
- `generation_task.py` research interception — stays as-is
|
||||
- `search_images` tool — stays as-is
|
||||
- `_search_searxng` and `_search_searxng_images` — stay as-is
|
||||
- `_fetch_full_article` in `rss.py` — stays as-is, reused by `lookup` for SearXNG fallback
|
||||
Generated
+393
-1276
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-static-copy": "^4.0.1",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppHeader from "@/components/AppHeader.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
@@ -12,6 +13,9 @@ import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
|
||||
scheduleVadPreload();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
|
||||
@@ -426,7 +426,9 @@ export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
|
||||
export async function openArticleInChat(
|
||||
itemId: number,
|
||||
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
|
||||
@@ -12,6 +13,7 @@ const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -78,7 +80,7 @@ router.afterEach(() => {
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +130,7 @@ router.afterEach(() => {
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiGet, transcribeAudio } from '@/api/client'
|
||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
import { useVad } from '@/composables/useVad'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
@@ -29,6 +32,32 @@ const emit = defineEmits<{
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
|
||||
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const speakerPopoverOpen = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core input ────────────────────────────────────────────────────────────────
|
||||
const messageInput = ref('')
|
||||
@@ -109,15 +138,50 @@ function removeAttachedNote() {
|
||||
attachedNote.value = null
|
||||
}
|
||||
|
||||
// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
|
||||
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
|
||||
const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
const silenceDetector = useSilenceDetector()
|
||||
// Tracks whether VAD detected speech during the current recording session.
|
||||
// Used to skip the Whisper round-trip when the user manually stops without
|
||||
// ever speaking — the recording is ambient noise and will transcribe to "".
|
||||
const vadSawSpeech = ref(false)
|
||||
|
||||
const vad = useVad({
|
||||
onSpeechEnd: () => {
|
||||
// VAD auto-stop: speech was detected and the user has paused. Proceed
|
||||
// to transcribe the MediaRecorder output.
|
||||
void stopRecording(false)
|
||||
},
|
||||
onNoSpeech: () => {
|
||||
useToastStore().show('No speech detected', 'warning')
|
||||
},
|
||||
onVadError: (msg) => {
|
||||
useToastStore().show(`VAD failed: ${msg}`, 'error')
|
||||
},
|
||||
})
|
||||
|
||||
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
|
||||
const micGlowStyle = computed(() => {
|
||||
if (!recorder.recording.value) return { display: 'none' }
|
||||
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
|
||||
return {
|
||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||
opacity: String(0.45 + pulse * 0.4),
|
||||
}
|
||||
})
|
||||
|
||||
// vad.speaking flips true on speech-start. Watch it once per session to
|
||||
// capture that speech was detected at some point, without clearing when
|
||||
// speech ends. This drives the no-speech guard in stopRecording.
|
||||
watch(() => vad.speaking.value, (on) => {
|
||||
if (on) vadSawSpeech.value = true
|
||||
})
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
await stopRecording()
|
||||
await stopRecording(true)
|
||||
} else {
|
||||
await startRecording()
|
||||
}
|
||||
@@ -129,22 +193,32 @@ async function startRecording() {
|
||||
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
||||
return
|
||||
}
|
||||
vadSawSpeech.value = false
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
useToastStore().show(recorder.error.value, 'error')
|
||||
return
|
||||
}
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, () => stopRecording())
|
||||
await vad.start(recorder.stream.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
silenceDetector.stop()
|
||||
async function stopRecording(manual: boolean) {
|
||||
if (manual) {
|
||||
await vad.stopAndCheck()
|
||||
} else {
|
||||
await vad.stop()
|
||||
}
|
||||
if (!recorder.recording.value) return
|
||||
transcribingVoice.value = true
|
||||
try {
|
||||
const blob = await recorder.stopRecording()
|
||||
// No-speech guard: user manually stopped without VAD seeing speech.
|
||||
// Skip Whisper — the toast was already shown by onNoSpeech.
|
||||
if (manual && !vadSawSpeech.value) {
|
||||
return
|
||||
}
|
||||
// Pass last assistant message as context to reduce STT mishearings
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
||||
@@ -159,6 +233,15 @@ async function stopRecording() {
|
||||
finally { transcribingVoice.value = false }
|
||||
}
|
||||
|
||||
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
if (!speakerPopoverOpen.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
|
||||
|
||||
// ── Exposed interface ─────────────────────────────────────────────────────────
|
||||
function focus() {
|
||||
inputEl.value?.focus()
|
||||
@@ -230,23 +313,72 @@ defineExpose({ focus, prefill })
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- PTT mic -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<!-- Speaker / listen-mode popover -->
|
||||
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
|
||||
<button
|
||||
class="btn-icon btn-speaker"
|
||||
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
|
||||
@click="speakerPopoverOpen = !speakerPopoverOpen"
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
class="speaker-row speaker-row--toggle"
|
||||
:class="{ 'speaker-row--active': listenMode }"
|
||||
@click="toggleListen"
|
||||
>
|
||||
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
|
||||
<span class="speaker-switch" :class="{ on: listenMode }"></span>
|
||||
</button>
|
||||
<div class="speaker-row">
|
||||
<span class="speaker-row-label">Volume</span>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="speaker-volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PTT mic (with live amplitude halo behind) -->
|
||||
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||
<button
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
@@ -343,9 +475,50 @@ defineExpose({ focus, prefill })
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
|
||||
.btn-mic.mic-recording {
|
||||
opacity: 1;
|
||||
/* White icon sits on top of the red halo so it stays legible at any
|
||||
pulse size. */
|
||||
color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
computed `micGlowStyle` so the user gets unmistakable feedback
|
||||
that their voice is being picked up while the mic icon itself
|
||||
stays put and readable on top. */
|
||||
.mic-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mic-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 68, 68, 0.9) 0%,
|
||||
rgba(239, 68, 68, 0.6) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||
rather than stepping. */
|
||||
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
@@ -418,4 +591,77 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
/* Speaker / listen-mode popover */
|
||||
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
|
||||
.speaker-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.35rem;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.speaker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.speaker-row--toggle { cursor: pointer; }
|
||||
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
|
||||
.speaker-row--active { color: var(--color-primary); }
|
||||
.speaker-row--stop {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
|
||||
.speaker-row-label { flex: 1; }
|
||||
.speaker-volume-range { flex: 1; min-width: 0; }
|
||||
.speaker-volume-pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.speaker-switch {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-border);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.speaker-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-card);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.speaker-switch.on { background: var(--color-primary); }
|
||||
.speaker-switch.on::after { transform: translateX(12px); }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { apiGet } from '@/api/client'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue'
|
||||
@@ -34,33 +29,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const store = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// ── Voice / TTS ───────────────────────────────────────────────────────────────
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
const listenMode = useListenMode()
|
||||
const audio = useVoiceAudio()
|
||||
const showVolumeSlider = ref(false)
|
||||
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
|
||||
function lastAssistantContent(): string {
|
||||
const msgs = store.currentConversation?.messages ?? []
|
||||
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
listenMode.value = !listenMode.value
|
||||
if (listenMode.value) {
|
||||
tts.speak(lastAssistantContent())
|
||||
} else {
|
||||
tts.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scroll ────────────────────────────────────────────────────────────────────
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
@@ -89,47 +57,6 @@ watch(() => store.streamingToolCalls, (calls) => {
|
||||
}, { deep: true })
|
||||
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
|
||||
|
||||
// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
|
||||
const projects = ref<{ id: number; title: string }[]>([])
|
||||
const scopeDropdownOpen = ref(false)
|
||||
const scopePulse = ref(false)
|
||||
|
||||
const showScopeChip = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId
|
||||
)
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const id = store.ragProjectId
|
||||
if (id === -1) return 'All notes'
|
||||
if (id === null) return 'Orphan notes'
|
||||
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
|
||||
projects.value = data.projects ?? []
|
||||
} catch {
|
||||
projects.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeSelect(value: number | null) {
|
||||
scopeDropdownOpen.value = false
|
||||
const convId = store.currentConversation?.id
|
||||
if (!convId) return
|
||||
await store.updateRagScope(convId, value)
|
||||
scopePulse.value = true
|
||||
setTimeout(() => { scopePulse.value = false }, 600)
|
||||
}
|
||||
|
||||
// Pulse when model-driven scope change fires
|
||||
watch(() => store.ragProjectId, () => {
|
||||
if (!showScopeChip.value) return
|
||||
scopePulse.value = true
|
||||
setTimeout(() => { scopePulse.value = false }, 600)
|
||||
})
|
||||
|
||||
// ── Note context (full variant — included / suggested / auto-injected notes) ──
|
||||
const includedNoteIds = ref<Set<number>>(new Set())
|
||||
const includedNotes = ref<{ id: number; title: string }[]>([])
|
||||
@@ -190,11 +117,92 @@ function removeIncludedNote(noteId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const hasContextSidebar = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId &&
|
||||
(autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
|
||||
const contextCount = computed(
|
||||
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
|
||||
)
|
||||
|
||||
const hasContextData = computed(
|
||||
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
|
||||
)
|
||||
|
||||
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
|
||||
const NARROW_BREAKPOINT = 1200
|
||||
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
function onResize() {
|
||||
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
|
||||
}
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
function toggleContextSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
|
||||
|
||||
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
|
||||
type SectionKey = 'auto' | 'suggested' | 'included'
|
||||
const collapsedSections = ref<Set<SectionKey>>(new Set())
|
||||
|
||||
function storageKey(): string | null {
|
||||
const id = store.currentConversation?.id
|
||||
return id ? `fa_chat_ctx_collapsed_${id}` : null
|
||||
}
|
||||
|
||||
function loadCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) { collapsedSections.value = new Set(); return }
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) { collapsedSections.value = new Set(); return }
|
||||
const arr = JSON.parse(raw) as SectionKey[]
|
||||
collapsedSections.value = new Set(arr)
|
||||
} catch {
|
||||
collapsedSections.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function toggleSection(key: SectionKey) {
|
||||
const next = new Set(collapsedSections.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
collapsedSections.value = next
|
||||
saveCollapsed()
|
||||
}
|
||||
|
||||
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
|
||||
|
||||
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
props.variant === 'full'
|
||||
&& !props.briefingMode
|
||||
&& !props.projectId
|
||||
&& !store.streaming
|
||||
&& (store.currentConversation?.messages.length ?? 0) === 0
|
||||
)
|
||||
|
||||
const recentConversations = computed(() => {
|
||||
const currentId = store.currentConversation?.id
|
||||
return store.conversations
|
||||
.filter((c) => c.id !== currentId && c.message_count > 0)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function startVoiceMode() {
|
||||
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────────
|
||||
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
|
||||
|
||||
@@ -271,8 +279,7 @@ async function handleSaveAsNote(messageId: number) {
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
if (showScopeChip.value) await loadProjects()
|
||||
onMounted(() => {
|
||||
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
|
||||
})
|
||||
|
||||
@@ -306,13 +313,13 @@ function hasEarlierBriefingSlot(index: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({ focus, prefill, send })
|
||||
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
|
||||
<template v-if="variant === 'full'">
|
||||
<div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
|
||||
<div class="chat-full">
|
||||
<!-- Message list -->
|
||||
<div ref="messagesEl" class="messages-container">
|
||||
<div class="messages-inner">
|
||||
@@ -350,8 +357,27 @@ defineExpose({ focus, prefill, send })
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showEmptyState" class="chat-empty-state">
|
||||
<h2 class="empty-greeting">What's on your mind?</h2>
|
||||
<div v-if="recentConversations.length" class="empty-recent">
|
||||
<div class="empty-section-label">Jump back in</div>
|
||||
<router-link
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:to="`/chat/${conv.id}`"
|
||||
class="empty-recent-item"
|
||||
>
|
||||
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
|
||||
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
||||
<span class="voice-icon">🎤</span>
|
||||
<span>Start in voice mode</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>Start a conversation.</p>
|
||||
</div>
|
||||
@@ -360,114 +386,71 @@ defineExpose({ focus, prefill, send })
|
||||
<!-- Context sidebar (full, non-briefing, non-workspace) -->
|
||||
<aside v-if="hasContextSidebar" class="context-sidebar">
|
||||
<template v-if="autoInjectedNotes.length">
|
||||
<div class="context-sidebar-header">Auto-included</div>
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)">×</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('auto') }"
|
||||
@click="toggleSection('auto')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>Auto-included</span>
|
||||
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('auto')">
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="suggestedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
||||
@click="toggleSection('suggested')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>Suggested</span>
|
||||
<span class="section-count">{{ suggestedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('suggested')">
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)">×</button>
|
||||
<button
|
||||
class="context-sidebar-header"
|
||||
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
||||
@click="toggleSection('included')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<span>In Context</span>
|
||||
<span class="section-count">{{ includedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('included')">
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Scope chip -->
|
||||
<div v-if="showScopeChip" class="scope-chip-row">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listen / volume controls -->
|
||||
<div v-if="voiceTtsEnabled" class="voice-controls">
|
||||
<div class="volume-wrapper">
|
||||
<button
|
||||
class="btn-voice"
|
||||
:class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
|
||||
@click="toggleListen"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||||
aria-label="Toggle listen mode"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-voice btn-volume-icon"
|
||||
@click="showVolumeSlider = !showVolumeSlider"
|
||||
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showVolumeSlider" class="volume-popup">
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
:value="audio.volume.value"
|
||||
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
||||
class="volume-range"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="tts.speaking.value"
|
||||
class="btn-voice"
|
||||
@click="tts.stop()"
|
||||
title="Stop playback"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input area (hidden when readOnly) -->
|
||||
<div v-if="!readOnly" class="input-wrapper">
|
||||
<!-- Unified input bar -->
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
@@ -476,6 +459,7 @@ defineExpose({ focus, prefill, send })
|
||||
@submit="onSubmit"
|
||||
@abort="store.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -528,18 +512,31 @@ defineExpose({ focus, prefill, send })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Full variant layout ── */
|
||||
.chat-body {
|
||||
/* ── Full variant layout ──
|
||||
* Single grid owns the reading column + context sidebar + input bar so
|
||||
* messages and input bar share one centered reading track while the
|
||||
* context sidebar spans the full height from header to bottom of view.
|
||||
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
|
||||
* The context sidebar overlays the right gutter so it never shifts layout.
|
||||
*/
|
||||
.chat-full {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
minmax(0, var(--chat-reading-width))
|
||||
1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
}
|
||||
@@ -558,31 +555,79 @@ defineExpose({ focus, prefill, send })
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Context sidebar */
|
||||
/* Context sidebar — overlays right gutter, never shifts reading column */
|
||||
.context-sidebar {
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--chat-context-sidebar-width);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.78rem;
|
||||
z-index: 2;
|
||||
}
|
||||
.context-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.15rem 0.1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.context-sidebar-header:hover { color: var(--color-text); }
|
||||
.context-sidebar-header .chev {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.context-sidebar-header.collapsed .chev {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
||||
.context-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.context-note-auto {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.context-note-included {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.context-note-name {
|
||||
flex: 1;
|
||||
@@ -616,103 +661,16 @@ defineExpose({ focus, prefill, send })
|
||||
.context-note-remove:hover { color: #ef4444; }
|
||||
.context-note-add:hover { color: var(--color-primary); }
|
||||
|
||||
/* Input wrapper */
|
||||
/* Input wrapper — lives in the same reading column as messages */
|
||||
.input-wrapper {
|
||||
border-top: 1px solid var(--color-border);
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Scope chip */
|
||||
.scope-chip-row { display: flex; }
|
||||
.scope-chip-wrapper { position: relative; }
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
/* Voice controls */
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
|
||||
.btn-voice {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.65;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-voice:hover { opacity: 1; }
|
||||
.btn-voice--active { opacity: 1; color: var(--color-primary); }
|
||||
.btn-voice--busy { opacity: 1; color: #f59e0b; }
|
||||
.volume-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
}
|
||||
.volume-range { width: 80px; }
|
||||
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
|
||||
/* Queued messages */
|
||||
.queued-message { opacity: 0.45; }
|
||||
.queued-bubble {
|
||||
@@ -754,6 +712,108 @@ defineExpose({ focus, prefill, send })
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.5rem;
|
||||
padding: 3rem 1rem 2rem;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-recent-item + .empty-recent-item {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.empty-recent-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-recent-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-voice-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Widget variant ── */
|
||||
.widget-response {
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ForecastDay {
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
@@ -21,6 +23,7 @@ interface WeatherData {
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function hasPrecip(day: ForecastDay): boolean {
|
||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||
(day.precip_mm != null && day.precip_mm > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,22 +93,39 @@ const fetchedAtLabel = computed(() => {
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div class="weather-forecast" v-if="weather.forecast.length">
|
||||
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
|
||||
<span class="forecast-day-name">{{ day.day }}</span>
|
||||
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
|
||||
<span class="forecast-condition">{{ day.condition }}</span>
|
||||
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
|
||||
<span v-if="day.precip_probability != null && day.precip_probability > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_probability }}%
|
||||
</span>
|
||||
<span v-else-if="day.precip_mm != null && day.precip_mm > 0" class="forecast-precip">
|
||||
💧 {{ day.precip_mm.toFixed(1) }}mm
|
||||
</span>
|
||||
<span v-else class="forecast-precip forecast-precip--dry">💧 —</span>
|
||||
<span class="forecast-wind">💨 {{ day.windspeed_max }} {{ weather.wind_unit ?? 'km/h' }}</span>
|
||||
</div>
|
||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||
💧 {{ weather.precip_summary }}
|
||||
</div>
|
||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Hi / Lo</th>
|
||||
<th>💧</th>
|
||||
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in weather.forecast" :key="day.day">
|
||||
<td class="forecast-day-name">{{ day.day }}</td>
|
||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
||||
<template v-if="day.precip_summary">
|
||||
<span class="precip-detail" :title="day.precip_summary">
|
||||
{{ day.precip_probability }}%
|
||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="weather-card weather-unavailable">
|
||||
Weather data unavailable — will retry at next slot.
|
||||
@@ -115,6 +140,7 @@ const fetchedAtLabel = computed(() => {
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
@@ -142,12 +168,12 @@ const fetchedAtLabel = computed(() => {
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -159,7 +185,7 @@ const fetchedAtLabel = computed(() => {
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -168,48 +194,59 @@ const fetchedAtLabel = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
.weather-precip-summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.weather-forecast-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 4.5rem;
|
||||
.weather-forecast {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.weather-forecast thead th {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 0.4rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-forecast thead th:first-child,
|
||||
.weather-forecast thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weather-forecast tbody td {
|
||||
padding: 0.3rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.forecast-day-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forecast-icon {
|
||||
font-size: 1.2rem;
|
||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forecast-condition {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.forecast-precip,
|
||||
.forecast-wind {
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
.forecast-precip {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -217,6 +254,23 @@ const fetchedAtLabel = computed(() => {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.precip-detail {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.precip-peak {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forecast-wind {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import ChatPanel from '@/components/ChatPanel.vue';
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue';
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'note-changed': [noteId: number];
|
||||
'task-changed': [];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// SSE watcher — emit refresh events when tool calls succeed during streaming
|
||||
const processedCount = ref(0);
|
||||
|
||||
watch(
|
||||
() => chatStore.streamingToolCalls,
|
||||
(calls) => {
|
||||
for (let i = processedCount.value; i < calls.length; i++) {
|
||||
const tc = calls[i];
|
||||
if (tc.status !== 'success') continue;
|
||||
|
||||
// Note-editor refresh: any successful create_note/update_note
|
||||
if (['create_note', 'update_note'].includes(tc.function) && tc.result?.data?.id) {
|
||||
emit('note-changed', tc.result.data.id as number);
|
||||
}
|
||||
|
||||
// Task-panel refresh: milestone changes, or note changes where the note is a task (has status)
|
||||
const isTaskNoteChange =
|
||||
['create_note', 'update_note'].includes(tc.function) &&
|
||||
tc.result?.data?.status !== undefined;
|
||||
const isMilestoneChange = ['create_milestone', 'update_milestone'].includes(tc.function);
|
||||
if (isTaskNoteChange || isMilestoneChange) {
|
||||
emit('task-changed');
|
||||
}
|
||||
}
|
||||
processedCount.value = calls.length;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.streaming,
|
||||
(s) => {
|
||||
if (!s) processedCount.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
type WidgetState = 'collapsed' | 'expanded';
|
||||
const widgetState = ref<WidgetState>('collapsed');
|
||||
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null);
|
||||
|
||||
const workspaceConvId = ref<number | null>(null);
|
||||
let isNewConv = false;
|
||||
|
||||
const historyOpen = ref(false);
|
||||
|
||||
const projectConversations = computed(() =>
|
||||
chatStore.conversations
|
||||
.filter((c) => c.rag_project_id === props.projectId)
|
||||
.slice()
|
||||
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
|
||||
);
|
||||
|
||||
async function pickConversation(convId: number) {
|
||||
historyOpen.value = false;
|
||||
if (convId === workspaceConvId.value) return;
|
||||
await chatStore.fetchConversation(convId);
|
||||
workspaceConvId.value = convId;
|
||||
// The picked conversation already exists on the server; not ours to clean up.
|
||||
isNewConv = false;
|
||||
localStorage.setItem(_storageKey(props.projectId), String(convId));
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
historyOpen.value = !historyOpen.value;
|
||||
}
|
||||
|
||||
function conversationLabel(c: { id: number; title?: string | null }): string {
|
||||
return (c.title && c.title.trim()) || `Conversation #${c.id}`;
|
||||
}
|
||||
|
||||
function _storageKey(pid: number) {
|
||||
return `workspace_conv_${pid}`;
|
||||
}
|
||||
|
||||
function expand() {
|
||||
widgetState.value = 'expanded';
|
||||
nextTick(() => chatPanelRef.value?.focus());
|
||||
}
|
||||
function collapse() {
|
||||
widgetState.value = 'collapsed';
|
||||
nextTick(() => inputBarRef.value?.focus());
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
const conv = await chatStore.createConversation();
|
||||
workspaceConvId.value = conv.id;
|
||||
isNewConv = true;
|
||||
localStorage.setItem(_storageKey(props.projectId), String(conv.id));
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
}
|
||||
|
||||
/** Auto-expand and send — used when user submits from collapsed input. */
|
||||
async function onCollapsedSubmit(payload: { content: string; contextNoteId?: number }) {
|
||||
widgetState.value = 'expanded';
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
if (widgetState.value === 'expanded') {
|
||||
chatPanelRef.value?.prefill(text);
|
||||
} else {
|
||||
inputBarRef.value?.prefill(text);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (chatStore.conversations.length === 0) {
|
||||
await chatStore.fetchConversations();
|
||||
}
|
||||
|
||||
const key = _storageKey(props.projectId);
|
||||
const storedId = localStorage.getItem(key);
|
||||
|
||||
if (storedId) {
|
||||
const existingId = Number(storedId);
|
||||
try {
|
||||
await chatStore.fetchConversation(existingId);
|
||||
workspaceConvId.value = existingId;
|
||||
isNewConv = false;
|
||||
chatStore.reconnectIfGenerating(existingId);
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceConvId.value === null) {
|
||||
const conv = await chatStore.createConversation();
|
||||
workspaceConvId.value = conv.id;
|
||||
isNewConv = true;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
localStorage.setItem(key, String(conv.id));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (workspaceConvId.value !== null && isNewConv) {
|
||||
const id = workspaceConvId.value;
|
||||
const conv = chatStore.conversations.find((c) => c.id === id);
|
||||
if (conv && conv.message_count === 0) {
|
||||
await chatStore.deleteConversation(id);
|
||||
localStorage.removeItem(_storageKey(props.projectId));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ prefill });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom-anchored chat dock: collapsed (input only) or expanded (full panel) -->
|
||||
<div
|
||||
class="ws-chat-widget"
|
||||
:class="{ 'ws-chat-widget--expanded': widgetState === 'expanded' }"
|
||||
>
|
||||
<!-- Header (expanded only) -->
|
||||
<div v-if="widgetState === 'expanded'" class="ws-chat-header">
|
||||
<span class="ws-chat-title">Project chat</span>
|
||||
<div class="ws-chat-header-actions">
|
||||
<div class="ws-chat-history-wrap">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
:class="{ active: historyOpen }"
|
||||
title="History"
|
||||
@click="toggleHistory"
|
||||
>▾</button>
|
||||
<div v-if="historyOpen" class="ws-chat-history-menu">
|
||||
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
|
||||
No past conversations
|
||||
</div>
|
||||
<button
|
||||
v-for="c in projectConversations"
|
||||
:key="c.id"
|
||||
class="ws-chat-history-item"
|
||||
:class="{ current: c.id === workspaceConvId }"
|
||||
@click="pickConversation(c.id)"
|
||||
>
|
||||
<span class="ws-chat-history-title">{{ conversationLabel(c) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">↻</button>
|
||||
<button class="btn-icon-sm" title="Collapse" @click="collapse">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded body: full ChatPanel (includes its own input bar) -->
|
||||
<div v-if="widgetState === 'expanded'" class="ws-chat-body">
|
||||
<ChatPanel
|
||||
ref="chatPanelRef"
|
||||
variant="full"
|
||||
:projectId="projectId"
|
||||
placeholder="Message the agent…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapsed: standalone input bar with expand affordance -->
|
||||
<div v-else class="ws-chat-collapsed">
|
||||
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="ws-chat-input-row">
|
||||
<ChatInputBar
|
||||
ref="inputBarRef"
|
||||
placeholder="Message the agent…"
|
||||
@submit="onCollapsedSubmit"
|
||||
@abort="chatStore.cancelGeneration()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws-chat-widget {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: var(--page-max-width);
|
||||
margin-inline: auto;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35), 0 -1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.ws-chat-widget--expanded {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.ws-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-chat-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ws-chat-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-icon-sm:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.btn-icon-sm:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-icon-sm.active {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.ws-chat-history-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.ws-chat-history-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 220px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 30;
|
||||
padding: 4px;
|
||||
}
|
||||
.ws-chat-history-empty {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.ws-chat-history-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ws-chat-history-item:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.ws-chat-history-item.current {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ws-chat-history-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-chat-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ws-chat-body :deep(.chat-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ws-chat-collapsed {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ws-chat-expand-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ws-chat-expand-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.ws-chat-input-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -457,12 +457,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
/* ── Left rail ── */
|
||||
.note-rail {
|
||||
width: 155px;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-left-color: var(--color-primary);
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
/**
|
||||
* Idle-preload the Silero VAD ONNX model and its companion assets.
|
||||
*
|
||||
* `@ricky0123/vad-web` pulls ~2MB of ONNX model + ~5MB of onnxruntime-web
|
||||
* WASM the first time `MicVAD.new()` runs. Calling `defaultModelFetcher`
|
||||
* during page idle warms the browser cache so the first mic click feels
|
||||
* instant. If the preload fails, VAD simply loads lazily on first click.
|
||||
*/
|
||||
const ready = ref(false)
|
||||
let loading = false
|
||||
|
||||
export function useOnnxPreloader() {
|
||||
async function preload() {
|
||||
if (ready.value || loading) return
|
||||
loading = true
|
||||
try {
|
||||
const { defaultModelFetcher } = await import('@ricky0123/vad-web')
|
||||
// Asset lives at site root because vite-plugin-static-copy drops it
|
||||
// there. Match whatever path MicVAD will use at call time (see useVad).
|
||||
await defaultModelFetcher('/silero_vad_v5.onnx')
|
||||
ready.value = true
|
||||
} catch {
|
||||
// Non-fatal — MicVAD will load the model itself on first use.
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreload() {
|
||||
if (ready.value) return
|
||||
if (typeof window === 'undefined') return
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as Window & { requestIdleCallback: (cb: () => void, opts: { timeout: number }) => void })
|
||||
.requestIdleCallback(() => void preload(), { timeout: 5000 })
|
||||
} else {
|
||||
setTimeout(() => void preload(), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
return { ready: readonly(ready), schedulePreload }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// getByteFrequencyData returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { MicVAD } from '@ricky0123/vad-web'
|
||||
|
||||
export interface UseVadOptions {
|
||||
onSpeechEnd: () => void
|
||||
onNoSpeech: () => void
|
||||
onVadError?: (message: string) => void
|
||||
graceMs?: number
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Silero VAD-based speech detector.
|
||||
*
|
||||
* Replaces amplitude-based silence detection. Uses the Silero VAD v5 ONNX
|
||||
* model via `@ricky0123/vad-web`. The package ships the model + audio
|
||||
* worklet, and depends on `onnxruntime-web`'s WASM. Vite is configured
|
||||
* (see `vite.config.ts`) to serve these assets from the site root, which
|
||||
* is why we pass `baseAssetPath: "/"` and `onnxWASMBasePath: "/"`.
|
||||
*
|
||||
* `amplitude` is computed separately from a Web Audio API analyser node
|
||||
* on the same stream — it drives the mic pulse animation and is not part
|
||||
* of the stop decision. VAD's `onSpeechEnd` is the stop trigger.
|
||||
*
|
||||
* If VAD never detects speech during a session, calling `stopAndCheck()`
|
||||
* fires `onNoSpeech` instead of treating the recording as transcribable.
|
||||
*/
|
||||
export function useVad(options: UseVadOptions) {
|
||||
const {
|
||||
onSpeechEnd,
|
||||
onNoSpeech,
|
||||
graceMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const speaking = ref(false)
|
||||
const amplitude = ref(0)
|
||||
const loaded = ref(false)
|
||||
|
||||
let micVad: MicVAD | null = null
|
||||
let audioCtx: AudioContext | null = null
|
||||
let analyserInterval: ReturnType<typeof setInterval> | null = null
|
||||
let speechDetected = false
|
||||
let speechStartedAt = 0
|
||||
let recordingStartedAt = 0
|
||||
let stopped = false
|
||||
|
||||
async function start(stream: MediaStream): Promise<void> {
|
||||
await stop()
|
||||
stopped = false
|
||||
speechDetected = false
|
||||
speechStartedAt = 0
|
||||
recordingStartedAt = Date.now()
|
||||
|
||||
// Visual-only amplitude signal. This does NOT drive the stop decision.
|
||||
audioCtx = new AudioContext()
|
||||
await audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 1024
|
||||
source.connect(analyser)
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
|
||||
analyserInterval = setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
||||
const rms = Math.sqrt(sumSq / samples.length)
|
||||
amplitude.value = Math.min(rms * 5, 1)
|
||||
}, 100)
|
||||
|
||||
try {
|
||||
const { MicVAD } = await import('@ricky0123/vad-web')
|
||||
micVad = await MicVAD.new({
|
||||
model: 'v5',
|
||||
baseAssetPath: '/',
|
||||
onnxWASMBasePath: '/',
|
||||
// MicVAD manages its own audio graph, so we hand it the same stream
|
||||
// the MediaRecorder is using. It won't consume or alter the stream.
|
||||
getStream: async () => stream,
|
||||
onSpeechStart: () => {
|
||||
speaking.value = true
|
||||
if (!speechDetected) {
|
||||
speechDetected = true
|
||||
speechStartedAt = Date.now()
|
||||
}
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
speaking.value = false
|
||||
if (stopped) return
|
||||
const now = Date.now()
|
||||
const totalElapsed = now - recordingStartedAt
|
||||
const sinceSpeechStart = speechStartedAt > 0 ? now - speechStartedAt : 0
|
||||
if (totalElapsed >= minRecordingMs && sinceSpeechStart >= graceMs) {
|
||||
stopped = true
|
||||
onSpeechEnd()
|
||||
}
|
||||
},
|
||||
onVADMisfire: () => {
|
||||
// Speech-like blip that was too short to count. Reset the local flag
|
||||
// so a true "no speech" session still hits onNoSpeech.
|
||||
speaking.value = false
|
||||
},
|
||||
})
|
||||
await micVad.start()
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
console.error('VAD init failed:', e)
|
||||
options.onVadError?.(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<{ hadSpeech: boolean }> {
|
||||
const had = speechDetected
|
||||
stopped = true
|
||||
speaking.value = false
|
||||
amplitude.value = 0
|
||||
|
||||
if (analyserInterval !== null) {
|
||||
clearInterval(analyserInterval)
|
||||
analyserInterval = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
await audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
if (micVad) {
|
||||
try {
|
||||
await micVad.pause()
|
||||
await micVad.destroy()
|
||||
} catch {
|
||||
// Already destroyed or failed — nothing to clean up further.
|
||||
}
|
||||
micVad = null
|
||||
}
|
||||
return { hadSpeech: had }
|
||||
}
|
||||
|
||||
async function stopAndCheck(): Promise<void> {
|
||||
const { hadSpeech } = await stop()
|
||||
if (!hadSpeech) {
|
||||
onNoSpeech()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
speaking: readonly(speaking),
|
||||
amplitude: readonly(amplitude),
|
||||
loaded: readonly(loaded),
|
||||
start,
|
||||
stop,
|
||||
stopAndCheck,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
() => settings.value.default_model || ""
|
||||
);
|
||||
|
||||
const rssEnabled = computed(
|
||||
() => settings.value.rss_enabled === "true"
|
||||
);
|
||||
|
||||
// Voice status — checked once on login, refreshable from Settings
|
||||
const voiceEnabled = ref(false);
|
||||
const voiceSttReady = ref(false);
|
||||
@@ -62,6 +66,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading,
|
||||
assistantName,
|
||||
defaultModel,
|
||||
rssEnabled,
|
||||
voiceEnabled,
|
||||
voiceSttReady,
|
||||
voiceTtsReady,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
listEvents,
|
||||
type BriefingConversation,
|
||||
type EventEntry,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
@@ -33,6 +36,7 @@ interface WeatherData {
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Setup wizard
|
||||
const showWizard = ref(false)
|
||||
@@ -56,8 +60,9 @@ const todayConvId = ref<number | null>(null)
|
||||
|
||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
|
||||
// Weather panel (left column)
|
||||
// Weather panel
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
interface CurrentConditions {
|
||||
@@ -89,6 +94,66 @@ async function loadWeather() {
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// Upcoming events (right column, below weather)
|
||||
const upcomingEvents = ref<EventEntry[]>([])
|
||||
|
||||
interface GroupedDay {
|
||||
label: string
|
||||
dateKey: string
|
||||
events: EventEntry[]
|
||||
}
|
||||
|
||||
const groupedEvents = computed<GroupedDay[]>(() => {
|
||||
const groups = new Map<string, EventEntry[]>()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
for (const ev of upcomingEvents.value) {
|
||||
const d = new Date(ev.start_dt)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(ev)
|
||||
}
|
||||
|
||||
const result: GroupedDay[] = []
|
||||
for (const [key, events] of groups) {
|
||||
const d = new Date(key + 'T00:00:00')
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
||||
let label: string
|
||||
if (diff === 0) label = 'Today'
|
||||
else if (diff === 1) label = 'Tomorrow'
|
||||
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
result.push({ label, dateKey: key, events })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function formatEventTime(ev: EventEntry): string {
|
||||
if (ev.all_day) return 'All day'
|
||||
const d = new Date(ev.start_dt)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// News panel (right column)
|
||||
const newsItems = ref<NewsItem[]>([])
|
||||
|
||||
@@ -112,6 +177,7 @@ async function loadAll() {
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
loadEvents(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
@@ -261,22 +327,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<!-- Forecast card -->
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
:weather="loc"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="panel-empty">No weather configured</div>
|
||||
</div>
|
||||
|
||||
<!-- Center column: Chat -->
|
||||
<!-- Left column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
@@ -287,8 +338,57 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: News -->
|
||||
<!-- Right column: Weather + News -->
|
||||
<div class="briefing-right">
|
||||
<!-- Weather section (sticky) -->
|
||||
<div class="weather-section" v-if="weatherData.length">
|
||||
<div class="weather-section-header">
|
||||
<div class="weather-tabs" v-if="weatherData.length > 1">
|
||||
<button
|
||||
v-for="(loc, i) in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
class="weather-tab"
|
||||
:class="{ active: selectedWeatherIdx === i }"
|
||||
@click="selectedWeatherIdx = i"
|
||||
>{{ (loc as WeatherData).location }}</button>
|
||||
</div>
|
||||
<button
|
||||
class="weather-refresh-btn"
|
||||
:class="{ spinning: refreshingWeather }"
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>↻</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming events -->
|
||||
<div class="events-section" v-if="groupedEvents.length">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Upcoming</div>
|
||||
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
||||
<div class="events-day-label">{{ group.label }}</div>
|
||||
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
||||
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="event-body">
|
||||
<span class="event-title">{{ ev.title }}</span>
|
||||
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News section (scrollable) -->
|
||||
<div v-if="settingsStore.rssEnabled" class="news-section">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Today's News</div>
|
||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||
@@ -333,6 +433,7 @@ onMounted(async () => {
|
||||
>💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,7 +449,7 @@ onMounted(async () => {
|
||||
|
||||
.briefing-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -421,27 +522,10 @@ onMounted(async () => {
|
||||
}
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
|
||||
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-left :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
|
||||
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 2;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -453,14 +537,160 @@ onMounted(async () => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
grid-column: 3;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.weather-section {
|
||||
flex-shrink: 0;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.weather-section :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.weather-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-refresh-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.weather-refresh-btn.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.weather-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.weather-tab {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.weather-tab:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.weather-tab.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Upcoming events ─────────────────────────────────────── */
|
||||
.events-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.events-cal-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.events-cal-link:hover { color: var(--color-primary); }
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.events-day-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.event-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.event-row:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
}
|
||||
.event-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.event-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.event-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.event-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -581,29 +811,18 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
@media (max-width: 900px) {
|
||||
.briefing-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
.briefing-header {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
max-height: 220px;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 260px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+295
-131
@@ -2,6 +2,7 @@
|
||||
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { apiGet } from "@/api/client";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -10,6 +11,49 @@ const store = useChatStore();
|
||||
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
const convSearchQuery = ref("");
|
||||
const headerKebabOpen = ref(false);
|
||||
const sidebarKebabOpen = ref(false);
|
||||
|
||||
// ── RAG scope chip ────────────────────────────────────────────────────────────
|
||||
const projects = ref<{ id: number; title: string }[]>([]);
|
||||
const scopeDropdownOpen = ref(false);
|
||||
const scopePulse = ref(false);
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const id = store.ragProjectId;
|
||||
if (id === -1) return "All notes";
|
||||
if (id === null) return "Orphan notes";
|
||||
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>(
|
||||
"/api/projects?status=active"
|
||||
);
|
||||
projects.value = data.projects ?? [];
|
||||
} catch {
|
||||
projects.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeSelect(value: number | null) {
|
||||
scopeDropdownOpen.value = false;
|
||||
const id = store.currentConversation?.id;
|
||||
if (!id) return;
|
||||
await store.updateRagScope(id, value);
|
||||
scopePulse.value = true;
|
||||
setTimeout(() => { scopePulse.value = false; }, 600);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.ragProjectId,
|
||||
() => {
|
||||
scopePulse.value = true;
|
||||
setTimeout(() => { scopePulse.value = false; }, 600);
|
||||
}
|
||||
);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
@@ -46,7 +90,12 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
const buckets: Record<string, typeof store.conversations> = {};
|
||||
const order: string[] = [];
|
||||
|
||||
for (const conv of store.conversations) {
|
||||
const q = convSearchQuery.value.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? store.conversations.filter((c) => (c.title || "").toLowerCase().includes(q))
|
||||
: store.conversations;
|
||||
|
||||
for (const conv of filtered) {
|
||||
const d = new Date(conv.updated_at);
|
||||
let key: string;
|
||||
if (d >= startOfToday) {
|
||||
@@ -69,6 +118,8 @@ const groupedConversations = computed((): ConvGroup[] => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
document.addEventListener("mousedown", onDocumentMousedown);
|
||||
loadProjects();
|
||||
await store.fetchConversations();
|
||||
if (convId.value) {
|
||||
if (store.currentConversation?.id !== convId.value) {
|
||||
@@ -187,36 +238,35 @@ async function handleSummarize() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Research modal ────────────────────────────────────────────────────────────
|
||||
const showResearchModal = ref(false);
|
||||
const researchTopic = ref("");
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
|
||||
function toggleResearchModal() {
|
||||
showResearchModal.value = !showResearchModal.value;
|
||||
if (showResearchModal.value) {
|
||||
researchTopic.value = "";
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
const contextCount = computed(() => chatPanelRef.value?.contextCount ?? 0);
|
||||
const contextSidebarOpen = computed(() => chatPanelRef.value?.sidebarOpen ?? false);
|
||||
function toggleContextSidebar() {
|
||||
chatPanelRef.value?.toggleContextSidebar();
|
||||
}
|
||||
|
||||
function submitResearch() {
|
||||
const topic = researchTopic.value.trim();
|
||||
if (!topic) return;
|
||||
showResearchModal.value = false;
|
||||
researchTopic.value = "";
|
||||
// Prefill sends via ChatPanel — user sees it in the input, then it auto-submits
|
||||
chatPanelRef.value?.send(`Research: ${topic}`);
|
||||
// ── Kebab dismissal ───────────────────────────────────────────────────────────
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (headerKebabOpen.value && !target?.closest(".header-kebab-wrapper")) {
|
||||
headerKebabOpen.value = false;
|
||||
}
|
||||
if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
|
||||
sidebarKebabOpen.value = false;
|
||||
}
|
||||
if (scopeDropdownOpen.value && !target?.closest(".scope-chip-wrapper")) {
|
||||
scopeDropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (showResearchModal.value) {
|
||||
showResearchModal.value = false;
|
||||
if (headerKebabOpen.value || sidebarKebabOpen.value || scopeDropdownOpen.value) {
|
||||
headerKebabOpen.value = false;
|
||||
sidebarKebabOpen.value = false;
|
||||
scopeDropdownOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (sidebarOpen.value) {
|
||||
@@ -228,6 +278,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
document.removeEventListener("mousedown", onDocumentMousedown);
|
||||
if (prevConvId) {
|
||||
const conv = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
@@ -246,13 +297,35 @@ onUnmounted(() => {
|
||||
></div>
|
||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="sidebar-top-bar">
|
||||
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
|
||||
<button
|
||||
class="btn-select-mode"
|
||||
:class="{ active: selectMode }"
|
||||
@click="toggleSelectMode"
|
||||
title="Select conversations"
|
||||
>Select</button>
|
||||
<button class="btn-new-conv btn-new-conv--full" @click="newConversation">+ New Chat</button>
|
||||
<div class="sidebar-search-row">
|
||||
<input
|
||||
v-model="convSearchQuery"
|
||||
class="conv-search-input"
|
||||
type="search"
|
||||
placeholder="Search conversations…"
|
||||
aria-label="Search conversations"
|
||||
/>
|
||||
<div class="sidebar-kebab-wrapper">
|
||||
<button
|
||||
class="btn-kebab"
|
||||
:class="{ active: sidebarKebabOpen }"
|
||||
@click="sidebarKebabOpen = !sidebarKebabOpen"
|
||||
aria-label="List actions"
|
||||
title="List actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="sidebarKebabOpen" class="kebab-menu">
|
||||
<button
|
||||
class="kebab-item"
|
||||
@click="sidebarKebabOpen = false; toggleSelectMode()"
|
||||
>{{ selectMode ? "Exit select mode" : "Select conversations" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectMode" class="bulk-bar">
|
||||
@@ -304,6 +377,10 @@ onUnmounted(() => {
|
||||
<p v-if="!store.conversations.length" class="empty-msg">
|
||||
No conversations yet
|
||||
</p>
|
||||
<p
|
||||
v-else-if="convSearchQuery && !groupedConversations.length"
|
||||
class="empty-msg"
|
||||
>No matches for “{{ convSearchQuery }}”</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -319,40 +396,68 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
|
||||
<!-- Research modal trigger -->
|
||||
<div class="research-wrapper">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleResearchModal"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
title="Research a topic"
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="showResearchModal" class="research-modal">
|
||||
<div class="research-modal-header">Research topic</div>
|
||||
<input
|
||||
class="research-topic-input"
|
||||
v-model="researchTopic"
|
||||
placeholder="e.g. quantum computing"
|
||||
@keydown.enter="submitResearch"
|
||||
@keydown.escape="showResearchModal = false"
|
||||
/>
|
||||
<div class="research-modal-actions">
|
||||
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
|
||||
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
|
||||
</div>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === null }"
|
||||
@click="onScopeSelect(null)"
|
||||
>Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === -1 }"
|
||||
@click="onScopeSelect(-1)"
|
||||
>All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="store.currentConversation.messages.length"
|
||||
class="btn-summarize"
|
||||
@click="handleSummarize"
|
||||
:disabled="summarizing || store.streaming"
|
||||
>{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
|
||||
v-if="contextCount > 0"
|
||||
class="btn-context-toggle"
|
||||
:class="{ active: contextSidebarOpen }"
|
||||
@click="toggleContextSidebar"
|
||||
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
|
||||
>
|
||||
<span class="context-icon">📎</span>
|
||||
<span class="context-label">Context</span>
|
||||
<span class="context-count-badge">{{ contextCount }}</span>
|
||||
</button>
|
||||
|
||||
<div class="header-kebab-wrapper">
|
||||
<button
|
||||
class="btn-kebab"
|
||||
:class="{ active: headerKebabOpen }"
|
||||
@click="headerKebabOpen = !headerKebabOpen"
|
||||
aria-label="Conversation actions"
|
||||
title="Conversation actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
|
||||
<button
|
||||
class="kebab-item"
|
||||
:disabled="!store.currentConversation.messages.length || summarizing || store.streaming"
|
||||
@click="headerKebabOpen = false; handleSummarize()"
|
||||
>{{ summarizing ? "Summarizing…" : "Summarize as Note" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatPanel
|
||||
@@ -397,12 +502,12 @@ onUnmounted(() => {
|
||||
|
||||
.sidebar-top-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-new-conv {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
@@ -412,23 +517,31 @@ onUnmounted(() => {
|
||||
font-size: 0.9rem;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-new-conv--full { width: 100%; }
|
||||
.btn-new-conv:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
|
||||
.btn-select-mode {
|
||||
background: none;
|
||||
.sidebar-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.conv-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-select-mode:hover,
|
||||
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.conv-search-input:focus { border-color: var(--color-primary); }
|
||||
.conv-search-input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
@@ -522,12 +635,12 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ChatPanel fills the remaining space in chat-main */
|
||||
/* ChatPanel fills the remaining space in chat-main. Layout (grid vs
|
||||
* flex) is owned by ChatPanel's own .chat-full root — don't force a
|
||||
* display here or it overrides the grid. */
|
||||
.chat-panel-fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
@@ -548,87 +661,138 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-summarize {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-summarize:hover:not(:disabled) {
|
||||
background: var(--color-primary); color: #fff; border-color: var(--color-primary);
|
||||
}
|
||||
.btn-summarize:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.btn-attach {
|
||||
/* Kebab button + dropdown menu — shared by header and sidebar */
|
||||
.btn-kebab {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-attach:hover { opacity: 1; }
|
||||
.btn-attach:disabled { opacity: 0.25; cursor: default; }
|
||||
.btn-kebab:hover { color: var(--color-text); background: var(--color-bg-secondary); }
|
||||
.btn-kebab.active { color: var(--color-primary); }
|
||||
|
||||
.research-wrapper { position: relative; }
|
||||
.research-modal {
|
||||
.header-kebab-wrapper,
|
||||
.sidebar-kebab-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* RAG scope chip (header) */
|
||||
.scope-chip-wrapper { position: relative; flex-shrink: 0; }
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { border-color: var(--color-border); }
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
|
||||
/* Context toggle button (header) */
|
||||
.btn-context-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-context-toggle:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-context-toggle.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.context-icon { font-size: 0.85rem; line-height: 1; }
|
||||
.context-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 20px var(--color-shadow);
|
||||
padding: 0.75rem;
|
||||
min-width: 280px;
|
||||
padding: 0.25rem;
|
||||
z-index: 20;
|
||||
}
|
||||
.research-modal-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.research-topic-input {
|
||||
.kebab-menu--right { left: auto; right: 0; }
|
||||
.kebab-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.research-topic-input:focus { border-color: var(--color-primary); }
|
||||
.research-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-research-cancel {
|
||||
background: none; border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm); padding: 0.3rem 0.65rem;
|
||||
font-size: 0.85rem; cursor: pointer; color: var(--color-text-muted);
|
||||
}
|
||||
.btn-research-cancel:hover { border-color: var(--color-text-muted); }
|
||||
.btn-research-go {
|
||||
background: var(--color-primary); color: #fff; border: none;
|
||||
border-radius: var(--radius-sm); padding: 0.3rem 0.75rem;
|
||||
font-size: 0.85rem; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.btn-research-go:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-research-go:not(:disabled):hover { opacity: 0.9; }
|
||||
.kebab-item:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-primary); }
|
||||
.kebab-item:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.no-conversation {
|
||||
flex: 1;
|
||||
|
||||
@@ -387,6 +387,14 @@ async function saveBriefingSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRss() {
|
||||
try {
|
||||
await store.updateSettings({ rss_enabled: store.rssEnabled ? "false" : "true" });
|
||||
} catch {
|
||||
toastStore.show("Failed to update RSS setting", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function addFeed() {
|
||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||
addingFeed.value = true;
|
||||
@@ -2209,8 +2217,20 @@ function formatUserDate(iso: string): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<!-- RSS toggle -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS / News</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" :checked="store.rssEnabled" @change="toggleRss" />
|
||||
Enable RSS feeds
|
||||
</label>
|
||||
<p class="field-hint">Subscribe to RSS/Atom feeds and include news in your briefings.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<div class="briefing-feeds-header">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
@@ -2252,7 +2272,7 @@ function formatUserDate(iso: string): string {
|
||||
</section>
|
||||
|
||||
<!-- News Preferences -->
|
||||
<section class="settings-section full-width">
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<h2>News Preferences</h2>
|
||||
<p class="section-desc">
|
||||
Tell the briefing what topics you care about. Topics are matched against
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projectId = computed(() => Number(route.params.projectId));
|
||||
@@ -21,142 +19,62 @@ interface Project {
|
||||
}
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||
const activeNoteId = ref<number | null>(null);
|
||||
let workspaceConvId: number | null = null;
|
||||
let isNewConv = false;
|
||||
|
||||
function _storageKey(pid: number) {
|
||||
return `workspace_conv_${pid}`;
|
||||
}
|
||||
|
||||
// Panel collapse state — persisted per project
|
||||
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
|
||||
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
||||
|
||||
function _loadPanelState(pid: number) {
|
||||
try {
|
||||
const raw = localStorage.getItem(_panelKey(pid));
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
// Ensure at least one panel is open after restore
|
||||
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
|
||||
return saved as { tasks: boolean; chat: boolean; notes: boolean };
|
||||
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
|
||||
const tasks = saved.tasks ?? true;
|
||||
const notes = saved.notes ?? true;
|
||||
// Guard: at least one panel must be open
|
||||
if (!tasks && !notes) return { tasks: true, notes: true };
|
||||
return { tasks, notes };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { tasks: true, chat: true, notes: true };
|
||||
return { tasks: true, notes: true };
|
||||
}
|
||||
|
||||
const panelOpen = ref({ tasks: true, chat: true, notes: true });
|
||||
const panelOpen = ref<{ tasks: boolean; notes: boolean }>({ tasks: true, notes: true });
|
||||
|
||||
const gridColumns = computed(() => {
|
||||
return [
|
||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
|
||||
].join(" ");
|
||||
});
|
||||
|
||||
// SSE watcher — auto-load notes/tasks when tool calls succeed
|
||||
const processedCount = ref(0);
|
||||
|
||||
watch(
|
||||
() => chatStore.streamingToolCalls,
|
||||
(calls) => {
|
||||
for (let i = processedCount.value; i < calls.length; i++) {
|
||||
const tc = calls[i];
|
||||
if (
|
||||
["create_note", "update_note"].includes(tc.function) &&
|
||||
tc.status === "success" &&
|
||||
tc.result?.data?.id
|
||||
) {
|
||||
activeNoteId.value = tc.result.data.id as number;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
if (
|
||||
tc.status === "success" && (
|
||||
["create_milestone", "update_milestone"].includes(tc.function) ||
|
||||
(tc.function === "create_note" && tc.result?.data?.status) ||
|
||||
(tc.function === "update_note" && tc.result?.data?.status !== undefined)
|
||||
)
|
||||
) {
|
||||
taskPanelRef.value?.reload();
|
||||
}
|
||||
}
|
||||
processedCount.value = calls.length;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.streaming,
|
||||
(s) => {
|
||||
if (!s) processedCount.value = 0;
|
||||
}
|
||||
);
|
||||
|
||||
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||
const open = panelOpen.value;
|
||||
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
|
||||
const openCount = [open.tasks, open.notes].filter(Boolean).length;
|
||||
if (open[panel] && openCount <= 1) return;
|
||||
panelOpen.value[panel] = !panelOpen.value[panel];
|
||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
chatPanelRef.value?.prefill(text);
|
||||
function onNoteChanged(noteId: number) {
|
||||
activeNoteId.value = noteId;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
|
||||
function onTaskChanged() {
|
||||
taskPanelRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Restore panel state
|
||||
panelOpen.value = _loadPanelState(projectId.value);
|
||||
|
||||
// Load project info
|
||||
try {
|
||||
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
||||
} catch {
|
||||
toast.show("Failed to load project", "error");
|
||||
}
|
||||
|
||||
const key = _storageKey(projectId.value);
|
||||
const storedId = localStorage.getItem(key);
|
||||
|
||||
if (storedId) {
|
||||
// Try to reuse the existing workspace conversation
|
||||
const existingId = Number(storedId);
|
||||
try {
|
||||
await chatStore.fetchConversation(existingId);
|
||||
workspaceConvId = existingId;
|
||||
isNewConv = false;
|
||||
chatStore.reconnectIfGenerating(existingId);
|
||||
} catch {
|
||||
// Conversation was deleted — create a fresh one
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceConvId === null) {
|
||||
// Create a new workspace conversation and persist its ID
|
||||
const conv = await chatStore.createConversation();
|
||||
workspaceConvId = conv.id;
|
||||
isNewConv = true;
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
localStorage.setItem(key, String(conv.id));
|
||||
}
|
||||
|
||||
nextTick(() => chatPanelRef.value?.focus());
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
// Only delete if we created a brand-new conversation this session and it's still empty
|
||||
if (workspaceConvId !== null && isNewConv) {
|
||||
const conv = chatStore.conversations.find((c) => c.id === workspaceConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
await chatStore.deleteConversation(workspaceConvId);
|
||||
localStorage.removeItem(_storageKey(projectId.value));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -176,13 +94,6 @@ onUnmounted(async () => {
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.chat }]"
|
||||
title="Toggle Chat panel"
|
||||
@click="togglePanel('chat')"
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.notes }]"
|
||||
title="Toggle Notes panel"
|
||||
@@ -193,9 +104,8 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Three-panel body -->
|
||||
<!-- Two-panel body -->
|
||||
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
||||
|
||||
<!-- Left: Tasks -->
|
||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||
<Transition name="panel-fade">
|
||||
@@ -209,35 +119,8 @@ onUnmounted(async () => {
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Center: Chat -->
|
||||
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
|
||||
<!-- Quick chips (shown when conversation is empty) -->
|
||||
<div
|
||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||
class="empty-chat-prompt"
|
||||
>
|
||||
<p class="empty-hint">What would you like to work on?</p>
|
||||
<div class="quick-chips">
|
||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChatPanel
|
||||
ref="chatPanelRef"
|
||||
variant="full"
|
||||
:projectId="projectId"
|
||||
placeholder="Message the agent… (Enter to send)"
|
||||
class="ws-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Right: Note Editor -->
|
||||
<div v-show="panelOpen.notes" class="ws-panel">
|
||||
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.notes" class="panel-inner">
|
||||
<WorkspaceNoteEditor
|
||||
@@ -249,13 +132,21 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Floating chat widget -->
|
||||
<WorkspaceChatWidget
|
||||
v-if="project"
|
||||
:project-id="projectId"
|
||||
@note-changed="onNoteChanged"
|
||||
@task-changed="onTaskChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -263,7 +154,6 @@ onUnmounted(async () => {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.ws-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -318,7 +208,6 @@ onUnmounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Three-panel layout */
|
||||
.ws-body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -331,6 +220,10 @@ onUnmounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws-panel-notes {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -345,60 +238,4 @@ onUnmounted(async () => {
|
||||
.panel-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Chat panel */
|
||||
.ws-panel-chat {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-inner-chat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ws-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-chat-prompt {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.quick-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.quick-chip {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.quick-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
-1
@@ -1,9 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
// @ricky0123/vad-web ships ONNX + audio-worklet assets and depends on
|
||||
// onnxruntime-web's WASM binaries. Copy them into the served root so
|
||||
// MicVAD can load them at runtime via `baseAssetPath: "/"`.
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "node_modules/@ricky0123/vad-web/dist/*.onnx",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/onnxruntime-web/dist/*.wasm",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
{
|
||||
src: "node_modules/onnxruntime-web/dist/*.mjs",
|
||||
dest: "",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
|
||||
@@ -143,10 +143,14 @@ def create_app() -> Quart:
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
|
||||
"base-uri 'self'; worker-src 'self';"
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; worker-src 'self' blob:;"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -66,6 +66,13 @@ class RssItem(Base):
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Note persisting the first-click discussion summary. Set by the article
|
||||
# discussion pipeline once the seeded chat completes its first assistant
|
||||
# reply; links back into RAG so re-discussing the same article lands the
|
||||
# prior summary in context.
|
||||
discussion_note_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@@ -54,11 +54,17 @@ async def put_config():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _rss_enabled() -> bool:
|
||||
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
|
||||
|
||||
|
||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_feeds():
|
||||
if not await _rss_enabled():
|
||||
return jsonify([])
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
||||
@@ -70,6 +76,8 @@ async def list_feeds():
|
||||
@briefing_bp.route("/feeds", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def add_feed():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"error": "RSS is disabled"}), 403
|
||||
data = await request.get_json()
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
@@ -120,6 +128,8 @@ async def delete_feed(feed_id: int):
|
||||
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_feeds():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"feeds_refreshed": 0, "new_items": 0})
|
||||
results = await rss_svc.refresh_all_feeds(g.user.id)
|
||||
total_new = sum(results.values())
|
||||
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
||||
@@ -128,6 +138,8 @@ async def refresh_feeds():
|
||||
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def recent_items():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"items": []})
|
||||
limit = min(int(request.args.get("limit", 20)), 100)
|
||||
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
||||
return jsonify({"items": items})
|
||||
@@ -155,6 +167,7 @@ async def get_weather():
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/current", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_current_weather():
|
||||
@@ -213,11 +226,14 @@ async def refresh_weather():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else {}
|
||||
temp_unit = config.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
config = {}
|
||||
temp_unit = "C"
|
||||
|
||||
locations = config.get("locations", {})
|
||||
refreshed = []
|
||||
for key, loc in locations.items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
@@ -229,11 +245,15 @@ async def refresh_weather():
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
refreshed.append(key)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
|
||||
return jsonify({"refreshed": refreshed})
|
||||
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
||||
@@ -454,6 +474,9 @@ async def list_news():
|
||||
offset — pagination offset (default 0)
|
||||
feed_id — optional integer filter by feed
|
||||
"""
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"items": [], "total": 0})
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
days = min(int(request.args.get("days", 2)), 90)
|
||||
@@ -532,54 +555,21 @@ async def discuss_article(item_id: int):
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Three-layer cache: context_prepared (post-map-reduce) → content_full
|
||||
# (raw trafilatura) → fresh fetch. Only the first miss pays the fetch
|
||||
# cost; only a large uncached article pays the map-reduce cost. Repeat
|
||||
# clicks on the same article skip straight to the chat turn.
|
||||
from fabledassistant.services.article_context import prepare_article_context
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
# Shared helper handles the three-layer cache (context_prepared →
|
||||
# content_full → fresh fetch), writes the synthetic read_article tool
|
||||
# exchange and the conversational seed user prompt into the conversation.
|
||||
# The /news from-article route calls the same helper so behavior stays
|
||||
# byte-identical across entry points.
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if article_content:
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
# Store synthetic assistant message with read_article tool result
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
# Conversational seed — invites a real discussion rather than asking for
|
||||
# a one-shot summary. The model sees the article context in the tool
|
||||
# result above and responds to this user turn as the start of an ongoing
|
||||
# conversation the user will steer with follow-ups.
|
||||
discuss_prompt = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
await add_message(conv_id, "user", discuss_prompt)
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
|
||||
@@ -510,47 +510,78 @@ async def delete_model_route():
|
||||
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_from_article(item_id: int):
|
||||
"""Create a chat conversation seeded with an RSS article's content."""
|
||||
"""Create a chat conversation seeded for article discussion and auto-run.
|
||||
|
||||
Mirrors the briefing ``discuss_article`` route: creates a fresh
|
||||
conversation, stages the shared synthetic read_article exchange + seed
|
||||
prompt, then kicks off generation so the client lands on an in-flight
|
||||
stream. The Flutter and web chat screens reconnect to the running buffer
|
||||
on mount.
|
||||
"""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem, RssFeed.title.label("feed_title"))
|
||||
_select(RssItem)
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
row = result.first()
|
||||
item = result.scalars().first()
|
||||
|
||||
if row is None:
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
item, feed_title = row
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
source = feed_title or "News"
|
||||
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
|
||||
seeded_text = f"**{source}**\n\n**{item.title}**"
|
||||
if content_body:
|
||||
seeded_text += f"\n\n{content_body}"
|
||||
if item.url:
|
||||
seeded_text += f"\n\nSource: {item.url}"
|
||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
# Roll back the empty conversation so the user doesn't end up with a
|
||||
# phantom entry in their chat list.
|
||||
try:
|
||||
await delete_conversation(uid, conv.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to clean up empty article conversation %s", conv.id)
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
async with _async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=seeded_text,
|
||||
msg_metadata={"rss_item_ids": [item_id]},
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
# Reload conversation so we see the two messages the helper just added.
|
||||
conv = await get_conversation(uid, conv.id)
|
||||
assert conv is not None
|
||||
|
||||
return jsonify({"conversation_id": conv.id}), 201
|
||||
history: list[dict] = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict: dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
assistant_msg = await add_message(conv.id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv.id, assistant_msg.id)
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model, uid, conv.id, conv.title or "", discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"conversation_id": conv.id,
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Prepare article bodies as conversation-ready context.
|
||||
|
||||
Used by the briefing ``discuss-article`` flow. A raw trafilatura extraction
|
||||
is often too large to drop whole into a chat history without eating the
|
||||
context window, so this module runs a map-reduce step over oversized
|
||||
articles and returns a compact, structured context that still preserves the
|
||||
article's meaning across sections.
|
||||
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
|
||||
A raw trafilatura extraction is often too large to drop whole into a chat
|
||||
history without eating the context window, so this module runs a map-reduce
|
||||
step over oversized articles and returns a compact, structured context that
|
||||
still preserves the article's meaning across sections.
|
||||
|
||||
Small articles pass through unchanged — map-reduce only fires when the raw
|
||||
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
|
||||
by the caller, so repeat discuss-clicks on the same article skip this work
|
||||
entirely.
|
||||
|
||||
The module also owns ``seed_article_discussion``, the shared routine that
|
||||
stages a synthetic ``read_article`` tool exchange plus a conversational seed
|
||||
prompt into a conversation. Both the briefing and ``/news`` entry points call
|
||||
it so the two flows stay byte-identical — the only thing that differs between
|
||||
them is whether the conversation already existed or was freshly created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,6 +24,9 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.chat import add_message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,3 +168,103 @@ async def prepare_article_context(
|
||||
for i, summary in enumerate(summaries)
|
||||
]
|
||||
return header + "\n\n".join(parts)
|
||||
|
||||
|
||||
# Conversational seed prompt for article discussions. Kept here so both the
|
||||
# briefing and /news entry points use the exact same wording. See
|
||||
# feedback_discuss_prompt_style memory: numbered checklists produce
|
||||
# assignment-completion responses; this conversational seed opens a dialogue.
|
||||
ARTICLE_DISCUSS_SEED = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
|
||||
|
||||
class EmptyArticleError(Exception):
|
||||
"""Raised when an article has no extractable body text.
|
||||
|
||||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||||
user sees a clear error instead of a hallucinated summary built from an
|
||||
empty synthetic tool result.
|
||||
"""
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Stage the synthetic read_article tool exchange + conversational seed.
|
||||
|
||||
Used by both the briefing ``discuss_article`` route and the ``/news``
|
||||
``from-article`` conversation creator. Handles the three-layer cache
|
||||
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
|
||||
messages into ``conv_id``:
|
||||
|
||||
1. An assistant message with a synthetic ``read_article`` tool_call whose
|
||||
``result.content`` carries the prepared article context. The message
|
||||
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
|
||||
hook in ``generation_task.py`` can locate it and persist the first
|
||||
reply as a discussion-summary Note.
|
||||
2. A user message with the shared conversational seed prompt.
|
||||
|
||||
Returns the seed prompt string so callers can pass it to ``run_generation``
|
||||
as ``user_content``.
|
||||
"""
|
||||
# Avoid circulars: rss helper imports article_context indirectly nowhere,
|
||||
# but keep this local for symmetry with the route-level imports it
|
||||
# replaces.
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
if not raw_body.strip():
|
||||
# Hard-fail rather than stage an empty synthetic tool result.
|
||||
# An empty `content` field silently tells the model "the article
|
||||
# has nothing in it" and it confabulates from RAG/history. Better
|
||||
# to surface a clean error to the user.
|
||||
logger.warning(
|
||||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||||
item.id, item.url,
|
||||
)
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if not article_content.strip():
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(
|
||||
conv_id,
|
||||
"assistant",
|
||||
"",
|
||||
status="complete",
|
||||
tool_calls=synthetic_tool_calls,
|
||||
msg_metadata={"rss_item_id": item.id, "article_seed": True},
|
||||
)
|
||||
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
|
||||
return ARTICLE_DISCUSS_SEED
|
||||
|
||||
@@ -20,18 +20,26 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
# ── External data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_external(user_id: int) -> dict:
|
||||
"""Collect RSS items and weather."""
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
"""Collect RSS items (when enabled) and weather."""
|
||||
from fabledassistant.services.weather import get_cached_weather
|
||||
|
||||
rss_items, weather = await asyncio.gather(
|
||||
get_recent_items(user_id, limit=20),
|
||||
get_cached_weather(user_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
rss_items: list = []
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
try:
|
||||
rss_items = await get_recent_items(user_id, limit=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
weather = await get_cached_weather(user_id)
|
||||
except Exception:
|
||||
weather = []
|
||||
|
||||
return {
|
||||
"rss_items": rss_items if not isinstance(rss_items, Exception) else [],
|
||||
"weather": weather if not isinstance(weather, Exception) else [],
|
||||
"rss_items": rss_items,
|
||||
"weather": weather,
|
||||
}
|
||||
|
||||
|
||||
@@ -371,7 +379,11 @@ async def run_compilation(
|
||||
if item.get("id")
|
||||
]
|
||||
|
||||
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
|
||||
weather_cards = [
|
||||
card for row in weather_rows
|
||||
if (card := parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
weather_card = weather_cards[0] if weather_cards else None
|
||||
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
||||
|
||||
@@ -362,10 +362,12 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
# Refresh external data first
|
||||
try:
|
||||
import json
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
await refresh_all_feeds(user_id)
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
await refresh_all_feeds(user_id)
|
||||
from fabledassistant.services import weather as wx
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
|
||||
@@ -187,6 +187,7 @@ async def add_message(
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
msg_metadata: dict | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
@@ -199,6 +200,8 @@ async def add_message(
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
if msg_metadata is not None:
|
||||
kwargs["msg_metadata"] = msg_metadata
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
|
||||
@@ -36,71 +36,84 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking decision
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `_should_think` is the single source of truth for whether a qwen3-class
|
||||
# model should engage chain-of-thought for a given request. Frontend callers
|
||||
# should NOT hardcode think=True — leave it False and let the classifier
|
||||
# decide from message content. An explicit think_requested=True still acts
|
||||
# as an override for callers (e.g. a future UI toggle or MCP client) that
|
||||
# want to force extended reasoning regardless of content.
|
||||
#
|
||||
# Why gate it: on qwen3:14b, thinking adds 5–20s of latency before the first
|
||||
# visible content token, and most conversational messages do not benefit.
|
||||
# Gating by content keeps quick chats fast while preserving reasoning depth
|
||||
# for prompts that actually need it.
|
||||
#
|
||||
# Models that don't support extended reasoning (e.g. llama3, mistral) simply
|
||||
# ignore the `think` parameter in the Ollama chat request, so the decision
|
||||
# here is harmless on non-thinking models.
|
||||
|
||||
async def _maybe_save_article_discussion_note(
|
||||
user_id: int, conv_id: int, reply_content: str,
|
||||
) -> None:
|
||||
"""Persist a seeded article-discussion's first reply as a Note.
|
||||
|
||||
# Keywords that strongly suggest the user wants reasoning / analysis. Matched
|
||||
# case-insensitively as whole-ish phrases.
|
||||
_THINK_KEYWORDS: tuple[str, ...] = (
|
||||
"why", "how does", "how do i", "how would", "how should",
|
||||
"explain", "analyze", "analyse", "compare", "contrast",
|
||||
"design", "architect", "architecture", "plan out", "strategize",
|
||||
"debug", "diagnose", "troubleshoot", "root cause",
|
||||
"review", "critique", "evaluate", "trade-off", "tradeoff", "trade off",
|
||||
"pros and cons", "step by step", "walk me through",
|
||||
"prove", "derive", "figure out", "work through",
|
||||
"discuss", # covers briefing /discuss-article + /discuss-topic entry points
|
||||
)
|
||||
Fires after ``run_generation`` completes. Looks for a synthetic
|
||||
read_article seed message on the conversation; if found AND the linked
|
||||
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
|
||||
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
|
||||
the same article are a no-op (already linked).
|
||||
|
||||
# Messages shorter than this and without any think-keyword are treated as
|
||||
# simple/conversational and skip the thinking phase.
|
||||
_SHORT_MESSAGE_CHARS = 80
|
||||
|
||||
# Messages longer than this are treated as substantive regardless of keywords.
|
||||
_LONG_MESSAGE_CHARS = 400
|
||||
|
||||
|
||||
def _should_think(user_content: str, think_requested: bool) -> bool:
|
||||
"""Return whether extended thinking should be used for this request.
|
||||
|
||||
``think_requested`` acts as an explicit override: if True, thinking is
|
||||
forced on regardless of content. If False (the default), the decision is
|
||||
made by inspecting the message: long or keyword-bearing messages get
|
||||
thinking; short conversational messages skip it.
|
||||
Failures are logged and swallowed — the chat UI should never break because
|
||||
Note persistence hit a snag.
|
||||
"""
|
||||
if think_requested:
|
||||
return True
|
||||
text = (user_content or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if len(text) >= _LONG_MESSAGE_CHARS:
|
||||
return True
|
||||
lowered = text.lower()
|
||||
if any(kw in lowered for kw in _THINK_KEYWORDS):
|
||||
return True
|
||||
if len(text) < _SHORT_MESSAGE_CHARS:
|
||||
return False
|
||||
# Medium-length message with no obvious reasoning cue: default off.
|
||||
return False
|
||||
try:
|
||||
if not reply_content or not reply_content.strip():
|
||||
return
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models.conversation import Message as _Message
|
||||
from fabledassistant.models.rss_feed import RssItem as _RssItem
|
||||
from fabledassistant.services.notes import create_note
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(_Message)
|
||||
.where(_Message.conversation_id == conv_id)
|
||||
.order_by(_Message.id.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
seed_meta = None
|
||||
for m in messages:
|
||||
meta = m.msg_metadata or {}
|
||||
if meta.get("article_seed") and meta.get("rss_item_id"):
|
||||
seed_meta = meta
|
||||
break
|
||||
if seed_meta is None:
|
||||
return
|
||||
item_id = int(seed_meta["rss_item_id"])
|
||||
item = await session.get(_RssItem, item_id)
|
||||
if item is None or item.discussion_note_id is not None:
|
||||
return
|
||||
article_title = (item.title or "Untitled article").strip()
|
||||
article_url = item.url
|
||||
article_topics = list(item.topics or [])
|
||||
|
||||
note_title = f"Article: {article_title}"[:200]
|
||||
body_parts = [f"**Source:** {article_url}"] if article_url else []
|
||||
body_parts.append(reply_content.strip())
|
||||
note_body = "\n\n".join(body_parts)
|
||||
tags = ["article-summary"] + [t for t in article_topics if t]
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
tags=tags,
|
||||
entity_meta={
|
||||
"source": "article_discussion",
|
||||
"rss_item_id": item_id,
|
||||
"url": article_url,
|
||||
"conversation_id": conv_id,
|
||||
},
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(_RssItem, item_id)
|
||||
if fresh is not None and fresh.discussion_note_id is None:
|
||||
fresh.discussion_note_id = note.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
|
||||
note.id, item_id, conv_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist article-discussion note for conv %d",
|
||||
conv_id, exc_info=True,
|
||||
)
|
||||
|
||||
# Human-readable labels for each tool, shown in the status indicator
|
||||
_TOOL_LABELS: dict[str, str] = {
|
||||
@@ -117,7 +130,7 @@ _TOOL_LABELS: dict[str, str] = {
|
||||
"update_event": "Updating calendar event",
|
||||
"delete_event": "Removing calendar event",
|
||||
"list_calendars": "Listing calendars",
|
||||
"search_web": "Searching the web",
|
||||
"lookup": "Looking up information",
|
||||
"research_topic": "Researching topic",
|
||||
}
|
||||
|
||||
@@ -289,11 +302,12 @@ async def run_generation(
|
||||
# Emit context event
|
||||
buf.append_event("context", {"context": context_meta})
|
||||
|
||||
# `_should_think` is authoritative — frontend callers pass think=False by
|
||||
# default and let this classifier decide based on message content. An
|
||||
# explicit think=True still forces on as an override.
|
||||
# Always think on qwen3-class models: reasoning mode is the only reliable
|
||||
# path for the tool-call template. Content-based gating was tried in 87fcaa6
|
||||
# but exposed silent-generation failures on short tool-intent prompts, since
|
||||
# the classifier had no way to tell that "create a task" needs a tool call.
|
||||
think_requested = think
|
||||
think = _should_think(user_content, think_requested)
|
||||
think = True
|
||||
|
||||
t_start = time.monotonic()
|
||||
timing: dict = {
|
||||
@@ -331,6 +345,14 @@ async def run_generation(
|
||||
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
|
||||
t_stream = time.monotonic()
|
||||
|
||||
approx_msg_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
||||
round_content_start = len(buf.content_so_far)
|
||||
round_output_tokens_start = timing.get("output_tokens") or 0
|
||||
round_prompt_tokens_start = timing.get("prompt_tokens") or 0
|
||||
logger.info(
|
||||
"CTX_DIAG round_start conv=%d round=%d num_ctx=%d msgs=%d approx_chars=%d think=%s",
|
||||
conv_id, _round, num_ctx, len(messages), approx_msg_chars, think,
|
||||
)
|
||||
async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx):
|
||||
if buf.cancel_event.is_set():
|
||||
cancelled = True
|
||||
@@ -429,6 +451,21 @@ async def run_generation(
|
||||
all_tool_calls.append(tool_record)
|
||||
buf.append_event("tool_call", {"tool_call": tool_record})
|
||||
|
||||
round_content_added = len(buf.content_so_far) - round_content_start
|
||||
round_output_tokens_added = (timing.get("output_tokens") or 0) - round_output_tokens_start
|
||||
round_prompt_tokens = (timing.get("prompt_tokens") or 0) - round_prompt_tokens_start
|
||||
headroom = num_ctx - round_prompt_tokens if round_prompt_tokens else None
|
||||
is_silent = (
|
||||
not round_tool_calls
|
||||
and round_content_added == 0
|
||||
and round_output_tokens_added > 0
|
||||
)
|
||||
logger.info(
|
||||
"CTX_DIAG round_end conv=%d round=%d think=%s prompt_tokens=%d output_tokens=%d headroom=%s content_added=%d tool_calls=%d silent=%s",
|
||||
conv_id, _round, think, round_prompt_tokens, round_output_tokens_added,
|
||||
headroom, round_content_added, len(round_tool_calls), is_silent,
|
||||
)
|
||||
|
||||
timing["generation_ms"] = int((time.monotonic() - t_stream) * 1000)
|
||||
|
||||
if cancelled:
|
||||
@@ -463,6 +500,29 @@ async def run_generation(
|
||||
# Strip model artifacts from final content
|
||||
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
|
||||
|
||||
# Silent-generation safety net: the model burned output tokens but
|
||||
# nothing landed in content or tool_calls (seen with qwen3:14b when
|
||||
# its tool-call emission doesn't parse). Show a visible fallback so
|
||||
# the user isn't staring at an empty bubble.
|
||||
if (
|
||||
not cancelled
|
||||
and not buf.content_so_far.strip()
|
||||
and not all_tool_calls
|
||||
and (timing.get("output_tokens") or 0) > 0
|
||||
):
|
||||
logger.warning(
|
||||
"Silent generation for conv %d: output_tokens=%s but empty content "
|
||||
"and no tool calls (model=%s)",
|
||||
conv_id, timing.get("output_tokens"), model,
|
||||
)
|
||||
fallback = (
|
||||
"I wasn't able to produce a usable response — the model generated "
|
||||
"tokens that couldn't be parsed as content or a tool call. "
|
||||
"Please try rephrasing, or try again."
|
||||
)
|
||||
buf.content_so_far = fallback
|
||||
buf.append_event("chunk", {"chunk": fallback})
|
||||
|
||||
# Final save
|
||||
logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d",
|
||||
conv_id, len(buf.content_so_far), len(all_tool_calls))
|
||||
@@ -526,6 +586,18 @@ async def run_generation(
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
# Persist article-discussion seed conversations as a Note on their
|
||||
# first assistant reply. This makes "Discuss" summaries part of RAG
|
||||
# so the knowledge base stops being amnesiac about articles the user
|
||||
# has already engaged with. The hook detects a seeded conversation by
|
||||
# finding a synthetic read_article assistant message whose
|
||||
# msg_metadata carries ``article_seed: True`` and whose rss_items row
|
||||
# has no discussion_note_id yet. Fire-and-forget so the done event
|
||||
# lands immediately.
|
||||
asyncio.create_task(_maybe_save_article_discussion_note(
|
||||
user_id, conv_id, buf.content_so_far,
|
||||
))
|
||||
|
||||
if should_gen_title:
|
||||
# Feed the title model the *raw* conversation turns only — never
|
||||
# the post-build_context ``messages`` list. ``build_context``
|
||||
|
||||
@@ -265,20 +265,31 @@ async def stream_chat_with_tools(
|
||||
) as resp:
|
||||
await _raise_ollama_error(resp, model)
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
# Silent-generation diagnostic: if Ollama reports non-zero eval_count
|
||||
# but we never yielded any thinking/content/tool_calls, something
|
||||
# in the frames isn't landing in a field we read. Capture the last
|
||||
# few frames so we can see what Ollama actually sent.
|
||||
yielded_anything = False
|
||||
recent_frames: list[str] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if len(recent_frames) >= 5:
|
||||
recent_frames.pop(0)
|
||||
recent_frames.append(line[:500])
|
||||
data = json.loads(line)
|
||||
msg = data.get("message", {})
|
||||
|
||||
# Thinking chunks (qwen3 chain-of-thought, only when think=True)
|
||||
thinking = msg.get("thinking", "")
|
||||
if thinking:
|
||||
yielded_anything = True
|
||||
yield ChatChunk(type="thinking", content=thinking)
|
||||
|
||||
# Content chunks
|
||||
chunk = msg.get("content", "")
|
||||
if chunk:
|
||||
yielded_anything = True
|
||||
yield ChatChunk(type="content", content=chunk)
|
||||
|
||||
# Collect tool calls from any message (some models
|
||||
@@ -294,13 +305,21 @@ async def stream_chat_with_tools(
|
||||
len(accumulated_tool_calls),
|
||||
json.dumps(accumulated_tool_calls)[:500],
|
||||
)
|
||||
yielded_anything = True
|
||||
yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls)
|
||||
else:
|
||||
logger.debug("Ollama done with no tool calls")
|
||||
eval_count = data.get("eval_count") or 0
|
||||
if not yielded_anything and eval_count > 0:
|
||||
logger.warning(
|
||||
"Ollama silent generation: model=%s eval_count=%d but no "
|
||||
"thinking/content/tool_calls were yielded. Last frames: %s",
|
||||
model, eval_count, recent_frames,
|
||||
)
|
||||
yield ChatChunk(
|
||||
type="done",
|
||||
prompt_tokens=data.get("prompt_eval_count"),
|
||||
output_tokens=data.get("eval_count"),
|
||||
output_tokens=eval_count,
|
||||
)
|
||||
break
|
||||
|
||||
@@ -568,18 +587,27 @@ async def build_context(
|
||||
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
|
||||
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
|
||||
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
|
||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
|
||||
]
|
||||
actions = [
|
||||
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
|
||||
"read_note", "list_notes", "list_tasks", "log_work", "search_notes",
|
||||
"create_project", "list_projects", "get_project", "update_project",
|
||||
"search_projects", "create_milestone", "update_milestone", "list_milestones",
|
||||
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
|
||||
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
|
||||
"get_rss_items", "add_rss_feed", "read_article",
|
||||
]
|
||||
if has_caldav:
|
||||
tool_lines[-1] = (
|
||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
|
||||
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
|
||||
)
|
||||
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
|
||||
tool_lines.append(
|
||||
"For calendar events, use ISO 8601 datetime format with the user's timezone offset (stated in context below). "
|
||||
"Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
|
||||
)
|
||||
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
|
||||
actions.append("lookup")
|
||||
if Config.searxng_enabled():
|
||||
actions.extend(["research_topic", "search_images"])
|
||||
tool_lines.append(f"Available actions: {', '.join(actions)}.")
|
||||
tool_lines.append(
|
||||
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
|
||||
"Always include the UTC offset when creating events (user's timezone is stated in context below)."
|
||||
@@ -658,7 +686,7 @@ async def build_context(
|
||||
f"\n\n--- Active Workspace ---\n"
|
||||
f"You are in the \"{wp.title}\" project workspace.\n"
|
||||
f"All notes and tasks you create or update MUST belong to this project.\n"
|
||||
f"Always pass project=\"{wp.title}\" when calling create_note or create_task.\n"
|
||||
f"Always pass project=\"{wp.title}\" when calling create_note.\n"
|
||||
f"--- End Active Workspace ---"
|
||||
)
|
||||
except Exception:
|
||||
@@ -724,18 +752,27 @@ async def build_context(
|
||||
orphan_only = rag_project_id is None
|
||||
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
|
||||
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
# Skip RAG auto-injection on the first turn of a seeded article discussion.
|
||||
# The article body is already the sole context the user wants — pulling in
|
||||
# unrelated orphan notes tricks the model into summarizing those instead.
|
||||
# Follow-up turns keep RAG on because by then the user's own messages drive
|
||||
# the query rather than the generic seed prompt.
|
||||
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
|
||||
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
|
||||
|
||||
if not found_scored:
|
||||
if not _skip_rag_for_article_seed:
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
|
||||
if not found_scored and not _skip_rag_for_article_seed:
|
||||
keywords = _extract_keywords(user_message)
|
||||
if keywords:
|
||||
try:
|
||||
@@ -774,6 +811,8 @@ async def build_context(
|
||||
"score": round(score, 2),
|
||||
})
|
||||
user_context_parts.append(
|
||||
"[The following are reference excerpts from the user's personal notes, "
|
||||
"not part of their message. Use them only if relevant to answering.]\n"
|
||||
"--- Relevant Notes ---\n"
|
||||
+ "\n\n".join(snippets)
|
||||
+ "\n--- End Relevant Notes ---"
|
||||
@@ -814,33 +853,6 @@ async def build_context(
|
||||
+ "\n--- End Included Notes ---"
|
||||
)
|
||||
|
||||
# Semantically relevant RSS news items
|
||||
try:
|
||||
from fabledassistant.services.embeddings import get_embedding, semantic_search_rss_items
|
||||
news_query_vec = await get_embedding(user_message)
|
||||
news_hits = await semantic_search_rss_items(user_id, news_query_vec)
|
||||
if news_hits:
|
||||
news_snippets = []
|
||||
for score, rss_item in news_hits:
|
||||
feed_title = getattr(rss_item, "feed_title", "") or ""
|
||||
excerpt = (rss_item.content or "")[:500].strip()
|
||||
news_snippets.append(
|
||||
f"[{feed_title or 'News'}] {rss_item.title} (relevance: {round(score * 100)}%)\n"
|
||||
+ (f"{excerpt}\n" if excerpt else "")
|
||||
+ f"URL: {rss_item.url}"
|
||||
)
|
||||
user_context_parts.append(
|
||||
"--- Recent News You've Seen ---\n"
|
||||
+ "\n\n".join(news_snippets)
|
||||
+ "\n--- End Recent News ---"
|
||||
)
|
||||
context_meta["rss_news"] = [
|
||||
{"id": item.id, "title": item.title, "score": round(score, 2)}
|
||||
for score, item in news_hits
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("RSS semantic search skipped", exc_info=True)
|
||||
|
||||
# URL content fetched from links in the user message
|
||||
urls = _find_urls(user_message)
|
||||
for url in urls[:2]:
|
||||
|
||||
@@ -10,6 +10,7 @@ import httpx
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
|
||||
from fabledassistant.services.notes import create_note, update_note
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
from fabledassistant.models.note import Note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -205,7 +206,7 @@ async def run_research_pipeline(
|
||||
queries = await _generate_sub_queries(topic, model)
|
||||
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
|
||||
|
||||
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
|
||||
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
|
||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||
if i > 0:
|
||||
await asyncio.sleep(0.2 * i)
|
||||
@@ -214,13 +215,22 @@ async def run_research_pipeline(
|
||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||
return query, results
|
||||
|
||||
search_results = await asyncio.gather(
|
||||
async def _wiki_for_query(query: str) -> list[dict]:
|
||||
return await wiki_search(query, limit=1)
|
||||
|
||||
searxng_task = asyncio.gather(
|
||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
||||
)
|
||||
wiki_task = asyncio.gather(
|
||||
*[_wiki_for_query(q) for q in queries]
|
||||
)
|
||||
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
|
||||
|
||||
# Deduplicate URLs across all queries
|
||||
seen_urls: set[str] = set()
|
||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
||||
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
|
||||
|
||||
for query, results in search_results:
|
||||
for result in results[:PAGES_PER_QUERY]:
|
||||
url = result.get("url", "")
|
||||
@@ -228,7 +238,21 @@ async def run_research_pipeline(
|
||||
seen_urls.add(url)
|
||||
url_tasks.append((url, result, query))
|
||||
|
||||
# Fetch all unique URLs in parallel
|
||||
# Add Wikipedia results (they already have content via extract)
|
||||
for query, wiki_hits in zip(queries, wiki_results):
|
||||
for hit in wiki_hits:
|
||||
url = hit.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
wiki_sources.append({
|
||||
"url": url,
|
||||
"title": hit["title"],
|
||||
"query": query,
|
||||
"snippet": hit["extract"][:200],
|
||||
"content": hit["extract"],
|
||||
})
|
||||
|
||||
# Fetch all unique SearXNG URLs in parallel
|
||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||
title = result.get("title", url)
|
||||
_status(f"Reading: {title[:60]}...")
|
||||
@@ -241,10 +265,12 @@ async def run_research_pipeline(
|
||||
"content": content,
|
||||
}
|
||||
|
||||
all_sources: list[dict] = list(await asyncio.gather(
|
||||
fetched_sources: list[dict] = list(await asyncio.gather(
|
||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||
))
|
||||
|
||||
all_sources = wiki_sources + fetched_sources
|
||||
|
||||
if not all_sources:
|
||||
raise ValueError(f"No results found for '{topic}'")
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ async def _check_requires(user_id: int, requires: str) -> bool:
|
||||
return await is_caldav_configured(user_id)
|
||||
if requires == "searxng":
|
||||
return Config.searxng_enabled()
|
||||
if requires == "rss":
|
||||
from fabledassistant.services.settings import get_setting
|
||||
return (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
},
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
requires="rss",
|
||||
)
|
||||
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
@@ -35,6 +36,7 @@ async def get_rss_items_tool(*, user_id, arguments, **_ctx):
|
||||
"category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."},
|
||||
},
|
||||
required=["url"],
|
||||
requires="rss",
|
||||
)
|
||||
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
|
||||
import asyncio as _asyncio
|
||||
@@ -70,7 +72,7 @@ async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, "
|
||||
"or to get the full content of a linked page. "
|
||||
"Do NOT use search_web for URLs — use this tool instead."
|
||||
"Do NOT use lookup for URLs — use this tool instead."
|
||||
),
|
||||
parameters={
|
||||
"url": {"type": "string", "description": "The URL to fetch and read"},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Web search, research, and image tools (require SearXNG)."""
|
||||
"""Web search, research, and image tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,29 +10,107 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_web",
|
||||
name="lookup",
|
||||
description=(
|
||||
"Quick web lookup — returns search result snippets for factual questions, current events, "
|
||||
"definitions, or version numbers. No note is saved. Use research_topic when the user wants "
|
||||
"a comprehensive written report saved as a note."
|
||||
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
||||
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
||||
"'how does Y work', current events, or version numbers. No note is saved. "
|
||||
"For comprehensive written reports saved as notes, use research_topic instead."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The search query"},
|
||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||
},
|
||||
required=["query"],
|
||||
requires="searxng",
|
||||
)
|
||||
async def search_web_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
async def lookup_tool(*, user_id, arguments, **_ctx):
|
||||
import asyncio
|
||||
|
||||
query = arguments.get("query", "")
|
||||
results = await _search_searxng(query)
|
||||
if not results:
|
||||
return {"success": False, "error": f"No results found for '{query}'"}
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
query = arguments.get("query", "").strip()
|
||||
if not query:
|
||||
return {"success": False, "error": "query is required"}
|
||||
|
||||
searxng_enabled = Config.searxng_enabled()
|
||||
|
||||
async def _searxng_results() -> list[dict]:
|
||||
if not searxng_enabled:
|
||||
return []
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
return await _search_searxng(query) or []
|
||||
|
||||
wiki, search_results = await asyncio.gather(
|
||||
wiki_summary(query),
|
||||
_searxng_results(),
|
||||
)
|
||||
|
||||
wiki_payload: dict | None = None
|
||||
if wiki:
|
||||
wiki_payload = {
|
||||
"title": wiki["title"],
|
||||
"extract": wiki["extract"],
|
||||
"url": wiki["url"],
|
||||
}
|
||||
thumb_url = wiki.get("thumbnail_url") or ""
|
||||
if thumb_url:
|
||||
from fabledassistant.services.images import fetch_and_store_image
|
||||
record = await fetch_and_store_image(
|
||||
url=thumb_url,
|
||||
title=wiki["title"],
|
||||
source_domain="en.wikipedia.org",
|
||||
)
|
||||
if record:
|
||||
wiki_payload["image"] = {
|
||||
"embed": f"![{wiki['title']}](/api/images/{record.id})",
|
||||
"citation": f"*Source: [Wikipedia]({wiki['url']})*",
|
||||
}
|
||||
|
||||
web_payload: list[dict] = []
|
||||
if search_results:
|
||||
# Sequential fetches: trafilatura/lxml is not safe to run concurrently
|
||||
# via run_in_executor — parallel calls can trip a libxml2 double-free.
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
for r in search_results[:2]:
|
||||
url = r.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
content = await _fetch_full_article(url)
|
||||
except Exception:
|
||||
content = None
|
||||
web_payload.append({
|
||||
"url": url,
|
||||
"title": r.get("title", url),
|
||||
"snippet": r.get("snippet", ""),
|
||||
"content": (content or "")[:4000],
|
||||
})
|
||||
|
||||
if not wiki_payload and not web_payload:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"data": {
|
||||
"query": query,
|
||||
"message": "No results found. You can answer from your own knowledge.",
|
||||
},
|
||||
}
|
||||
|
||||
data: dict = {
|
||||
"query": query,
|
||||
"wikipedia": wiki_payload,
|
||||
"web": web_payload,
|
||||
}
|
||||
if wiki_payload and wiki_payload.get("image"):
|
||||
data["image_instructions"] = (
|
||||
"If an image is relevant to your reply, embed it by writing the wikipedia.image.embed "
|
||||
"field verbatim, then the citation field on the next line. Otherwise omit it."
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "web_search",
|
||||
"data": {"query": query, "results": results, "count": len(results)},
|
||||
"type": "lookup",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +120,7 @@ async def search_web_tool(*, user_id, arguments, **_ctx):
|
||||
"Deep web research — searches multiple sources, synthesizes findings, and saves the result "
|
||||
"as a structured note with sections and citations. Use when the user says 'research', "
|
||||
"'look into', or wants a comprehensive write-up. Takes 30–120 seconds. "
|
||||
"For a quick factual answer without saving a note, use search_web."
|
||||
"For a quick factual answer without saving a note, use lookup."
|
||||
),
|
||||
parameters={
|
||||
"topic": {"type": "string", "description": "The topic or question to research"},
|
||||
@@ -64,7 +142,7 @@ async def research_topic_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="search_images",
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The image search query"},
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ OPEN_METEO_DAILY = (
|
||||
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
"precipitation_probability_max,weathercode,windspeed_10m_max"
|
||||
)
|
||||
OPEN_METEO_HOURLY = "precipitation_probability"
|
||||
|
||||
# WMO weather code → description (subset; covers the most common codes)
|
||||
_WMO_CODES: dict[int, str] = {
|
||||
@@ -93,6 +94,55 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
|
||||
return changes
|
||||
|
||||
|
||||
def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
|
||||
"""Build a human-readable precipitation summary from (hour, probability) pairs.
|
||||
|
||||
Returns None when no significant precipitation is expected.
|
||||
"""
|
||||
wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
|
||||
if not wet_hours:
|
||||
return None
|
||||
|
||||
peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
|
||||
daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
|
||||
if not daytime_hours:
|
||||
return None
|
||||
|
||||
wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
|
||||
if len(wet_daytime) >= 10:
|
||||
return f"Rain likely all day (up to {peak_prob}%)"
|
||||
|
||||
if not wet_daytime:
|
||||
return None
|
||||
|
||||
def _fmt_hour(h: int) -> str:
|
||||
if h == 0 or h == 24:
|
||||
return "12 AM"
|
||||
if h == 12:
|
||||
return "12 PM"
|
||||
return f"{h} AM" if h < 12 else f"{h - 12} PM"
|
||||
|
||||
start = wet_daytime[0]
|
||||
end = wet_daytime[-1]
|
||||
if start == end:
|
||||
return f"{peak_prob}% chance around {_fmt_hour(start)}"
|
||||
return f"Rain likely {_fmt_hour(start)}–{_fmt_hour(end + 1)} (up to {peak_prob}%)"
|
||||
|
||||
|
||||
def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
|
||||
"""Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
|
||||
hourly = raw.get("hourly", {})
|
||||
times = hourly.get("precipitation_probability", [])
|
||||
time_labels = hourly.get("time", [])
|
||||
pairs: list[tuple[int, int]] = []
|
||||
prefix = date_str + "T"
|
||||
for i, t in enumerate(time_labels):
|
||||
if t.startswith(prefix) and i < len(times) and times[i] is not None:
|
||||
hour = int(t[11:13])
|
||||
pairs.append((hour, times[i]))
|
||||
return pairs
|
||||
|
||||
|
||||
def parse_weather_card_data(
|
||||
cache_row,
|
||||
temp_unit: str = "C",
|
||||
@@ -138,6 +188,30 @@ def parse_weather_card_data(
|
||||
|
||||
wind_unit = "mph" if imperial else "km/h"
|
||||
|
||||
today_hourly = _extract_hourly_precip_for_date(raw, today_str)
|
||||
today_precip_summary = _summarize_precip(today_hourly)
|
||||
|
||||
def _forecast_day(d: dict) -> dict:
|
||||
entry: dict = {
|
||||
"day": day_label(d["date"]),
|
||||
"condition": d["description"],
|
||||
"high": to_temp(d["temp_max"]),
|
||||
"low": to_temp(d["temp_min"]),
|
||||
"precip_probability": d["precip_probability"],
|
||||
"precip_mm": d["precip_mm"],
|
||||
"windspeed_max": to_wind(d["windspeed_max"]),
|
||||
}
|
||||
hourly = _extract_hourly_precip_for_date(raw, d["date"])
|
||||
summary = _summarize_precip(hourly)
|
||||
if summary:
|
||||
entry["precip_summary"] = summary
|
||||
if hourly:
|
||||
peak = max(hourly, key=lambda x: x[1])
|
||||
if peak[1] >= 30:
|
||||
h = peak[0]
|
||||
entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
|
||||
return entry
|
||||
|
||||
return {
|
||||
"location": getattr(cache_row, "location_label", ""),
|
||||
"fetched_at": cache_row.fetched_at.isoformat(),
|
||||
@@ -148,18 +222,8 @@ def parse_weather_card_data(
|
||||
"yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
|
||||
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
|
||||
"wind_unit": wind_unit,
|
||||
"forecast": [
|
||||
{
|
||||
"day": day_label(d["date"]),
|
||||
"condition": d["description"],
|
||||
"high": to_temp(d["temp_max"]),
|
||||
"low": to_temp(d["temp_min"]),
|
||||
"precip_probability": d["precip_probability"],
|
||||
"precip_mm": d["precip_mm"],
|
||||
"windspeed_max": to_wind(d["windspeed_max"]),
|
||||
}
|
||||
for d in future_days
|
||||
],
|
||||
"precip_summary": today_precip_summary,
|
||||
"forecast": [_forecast_day(d) for d in future_days],
|
||||
}
|
||||
|
||||
|
||||
@@ -259,12 +323,13 @@ async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
|
||||
|
||||
|
||||
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions, hourly precip, and yesterday's data."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(OPEN_METEO_URL, params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"daily": OPEN_METEO_DAILY,
|
||||
"hourly": OPEN_METEO_HOURLY,
|
||||
"current_weather": "true",
|
||||
"past_days": 1,
|
||||
"timezone": "auto",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Wikipedia API: lightweight topic lookups and article search."""
|
||||
import logging
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
|
||||
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
||||
_TIMEOUT = 5.0
|
||||
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
|
||||
|
||||
|
||||
def _extract_thumbnail(data: dict) -> str:
|
||||
"""Prefer the 320px thumbnail (kinder to cache/bandwidth) over originalimage."""
|
||||
thumb = data.get("thumbnail", {}).get("source", "")
|
||||
if thumb:
|
||||
return thumb
|
||||
return data.get("originalimage", {}).get("source", "")
|
||||
|
||||
|
||||
async def wiki_summary(query: str) -> dict | None:
|
||||
"""Fetch a Wikipedia summary for a given title/query.
|
||||
|
||||
Does a direct title lookup via the REST v1 summary endpoint.
|
||||
Returns a dict with title, extract, url, and thumbnail_url on success;
|
||||
thumbnail_url is an empty string when the article has no image.
|
||||
Returns None on any failure.
|
||||
"""
|
||||
title = url_quote(query.replace(" ", "_"))
|
||||
url = f"{_SUMMARY_URL}/{title}"
|
||||
headers = {"User-Agent": _USER_AGENT}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, headers=headers, timeout=_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("type") == "disambiguation":
|
||||
logger.debug("wiki_summary: disambiguation page for %r", query)
|
||||
return None
|
||||
extract = data.get("extract", "").strip()
|
||||
if not extract:
|
||||
logger.debug("wiki_summary: empty extract for %r", query)
|
||||
return None
|
||||
return {
|
||||
"title": data.get("title", query),
|
||||
"extract": extract,
|
||||
"url": data.get("content_urls", {}).get("desktop", {}).get("page", ""),
|
||||
"thumbnail_url": _extract_thumbnail(data),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("wiki_summary failed for %r: %s", query, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
|
||||
"""Search Wikipedia and return summaries for the top results.
|
||||
|
||||
Uses the MediaWiki search API then fetches summaries for each hit.
|
||||
Skips disambiguation pages and empty extracts.
|
||||
Returns a list of dicts with title, extract, and url.
|
||||
"""
|
||||
headers = {"User-Agent": _USER_AGENT}
|
||||
params = {
|
||||
"action": "query",
|
||||
"list": "search",
|
||||
"srsearch": query,
|
||||
"srlimit": str(limit),
|
||||
"format": "json",
|
||||
}
|
||||
results: list[dict] = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
_SEARCH_URL, params=params, headers=headers, timeout=_TIMEOUT
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
hits = data.get("query", {}).get("search", [])
|
||||
for hit in hits:
|
||||
title = hit.get("title", "")
|
||||
encoded = url_quote(title.replace(" ", "_"))
|
||||
summary_url = f"{_SUMMARY_URL}/{encoded}"
|
||||
try:
|
||||
sr = await client.get(
|
||||
summary_url, headers=headers, timeout=_TIMEOUT, follow_redirects=True
|
||||
)
|
||||
sr.raise_for_status()
|
||||
sdata = sr.json()
|
||||
if sdata.get("type") == "disambiguation":
|
||||
logger.debug("wiki_search: skipping disambiguation %r", title)
|
||||
continue
|
||||
extract = sdata.get("extract", "").strip()
|
||||
if not extract:
|
||||
logger.debug("wiki_search: empty extract for %r", title)
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"title": sdata.get("title", title),
|
||||
"extract": extract,
|
||||
"url": sdata.get("content_urls", {})
|
||||
.get("desktop", {})
|
||||
.get("page", ""),
|
||||
"thumbnail_url": _extract_thumbnail(sdata),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("wiki_search: summary fetch failed for %r: %s", title, exc)
|
||||
except Exception as exc:
|
||||
logger.debug("wiki_search failed for %r: %s", query, exc)
|
||||
return []
|
||||
return results
|
||||
@@ -127,8 +127,10 @@ async def test_update_event_fires_caldav_push():
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_calendar_always_available():
|
||||
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured:
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured, \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock) as mock_setting:
|
||||
mock_configured.return_value = False
|
||||
mock_setting.return_value = "false"
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_hit():
|
||||
wiki_data = {
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
||||
"thumbnail_url": "",
|
||||
}
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["data"]["wikipedia"]["title"] == "QUIC"
|
||||
assert result["data"]["web"] == []
|
||||
assert "image" not in result["data"]["wikipedia"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
||||
searxng_results = [
|
||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["wikipedia"] is None
|
||||
assert len(result["data"]["web"]) == 1
|
||||
assert result["data"]["web"][0]["url"] == "https://example.com/quic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_parallel_both_sources():
|
||||
wiki_data = {
|
||||
"title": "President of the United States",
|
||||
"extract": "The president is the head of state.",
|
||||
"url": "https://en.wikipedia.org/wiki/President_of_the_United_States",
|
||||
"thumbnail_url": "",
|
||||
}
|
||||
searxng_results = [
|
||||
{"url": "https://whitehouse.gov", "title": "The White House", "snippet": "Current admin..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Current president is..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["wikipedia"]["title"] == "President of the United States"
|
||||
assert len(result["data"]["web"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_thumbnail_cached():
|
||||
wiki_data = {
|
||||
"title": "Hedgehog",
|
||||
"extract": "Hedgehogs are spiny mammals.",
|
||||
"url": "https://en.wikipedia.org/wiki/Hedgehog",
|
||||
"thumbnail_url": "https://upload.wikimedia.org/.../Hedgehog.jpg",
|
||||
}
|
||||
mock_record = MagicMock()
|
||||
mock_record.id = 42
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.images.fetch_and_store_image", new_callable=AsyncMock, return_value=mock_record):
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "hedgehog"})
|
||||
|
||||
assert result["data"]["wikipedia"]["image"]["embed"] == ""
|
||||
assert "Wikipedia" in result["data"]["wikipedia"]["image"]["citation"]
|
||||
assert "image_instructions" in result["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_no_searxng():
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.config.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert "message" in result["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_always_available():
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
assert "lookup" in tool_names
|
||||
@@ -123,8 +123,6 @@ async def test_pipeline_creates_section_notes_and_index():
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
mock_update = AsyncMock()
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
@@ -192,3 +190,42 @@ async def test_pipeline_falls_back_when_all_sections_fail():
|
||||
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
assert result.id == 77
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_wikipedia_sources():
|
||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
]
|
||||
|
||||
note_id_counter = iter(range(30, 40))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
# The sources passed to _generate_outline should include the Wikipedia article
|
||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
||||
source_urls = [s["url"] for s in sources_arg]
|
||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Tests for the `_should_think` classifier.
|
||||
|
||||
`_should_think` decides whether qwen3-class models should engage chain-of-
|
||||
thought for a given chat turn. It is the single source of truth: frontend
|
||||
callers pass `think_requested=False` by default and defer to this function,
|
||||
while explicit `think_requested=True` acts as an override for curated
|
||||
analytical entry points.
|
||||
|
||||
These tests lock in the content-based behavior so future tweaks don't
|
||||
silently regress the short / long / keyword boundaries.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.services.generation_task import (
|
||||
_LONG_MESSAGE_CHARS,
|
||||
_SHORT_MESSAGE_CHARS,
|
||||
_should_think,
|
||||
)
|
||||
|
||||
|
||||
class TestExplicitOverride:
|
||||
def test_override_forces_on_for_empty(self):
|
||||
assert _should_think("", think_requested=True) is True
|
||||
|
||||
def test_override_forces_on_for_short_greeting(self):
|
||||
assert _should_think("hi", think_requested=True) is True
|
||||
|
||||
def test_override_forces_on_for_medium_no_keyword(self):
|
||||
text = "just checking in on the status of things for the week"
|
||||
assert _should_think(text, think_requested=True) is True
|
||||
|
||||
|
||||
class TestEmptyAndWhitespace:
|
||||
def test_empty_string_off(self):
|
||||
assert _should_think("", think_requested=False) is False
|
||||
|
||||
def test_none_content_off(self):
|
||||
# _should_think defensively handles None content from upstream callers
|
||||
assert _should_think(None, think_requested=False) is False # type: ignore[arg-type]
|
||||
|
||||
def test_whitespace_only_off(self):
|
||||
assert _should_think(" \n\t ", think_requested=False) is False
|
||||
|
||||
|
||||
class TestShortMessages:
|
||||
def test_short_greeting_off(self):
|
||||
assert _should_think("hi", think_requested=False) is False
|
||||
|
||||
def test_short_thanks_off(self):
|
||||
assert _should_think("thanks!", think_requested=False) is False
|
||||
|
||||
def test_short_acknowledgement_off(self):
|
||||
assert _should_think("ok sounds good", think_requested=False) is False
|
||||
|
||||
def test_just_below_short_threshold_off(self):
|
||||
text = "a" * (_SHORT_MESSAGE_CHARS - 1)
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
|
||||
|
||||
class TestLongMessages:
|
||||
def test_at_long_threshold_on(self):
|
||||
text = "a" * _LONG_MESSAGE_CHARS
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
def test_well_above_long_threshold_on(self):
|
||||
text = "x" * (_LONG_MESSAGE_CHARS * 3)
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
|
||||
class TestMediumMessages:
|
||||
def test_medium_no_keyword_off(self):
|
||||
# Between the short and long thresholds with no reasoning cue.
|
||||
text = "a" * ((_SHORT_MESSAGE_CHARS + _LONG_MESSAGE_CHARS) // 2)
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
|
||||
|
||||
class TestKeywordTriggers:
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"why is this failing",
|
||||
"how does caching work here",
|
||||
"how do i configure this",
|
||||
"explain the retry logic",
|
||||
"analyze the latency breakdown",
|
||||
"compare gemma3 vs qwen3 for tool use",
|
||||
"please design the schema for X",
|
||||
"debug this error",
|
||||
"troubleshoot the connection issue",
|
||||
"root cause the outage",
|
||||
"review this PR",
|
||||
"critique my approach",
|
||||
"walk me through the flow",
|
||||
"step by step instructions please",
|
||||
"pros and cons of each option",
|
||||
"help me figure out what's wrong",
|
||||
"discuss this article", # covers briefing /discuss entry points
|
||||
],
|
||||
)
|
||||
def test_keyword_forces_on(self, text):
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
assert _should_think("WHY does this break?", think_requested=False) is True
|
||||
|
||||
def test_keyword_in_longer_sentence(self):
|
||||
text = "hey quick one — can you explain what caching does for qwen3"
|
||||
assert _should_think(text, think_requested=False) is True
|
||||
|
||||
|
||||
class TestNonTriggers:
|
||||
"""Messages that look chatty and should NOT trigger thinking."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"hey",
|
||||
"yep",
|
||||
"no worries",
|
||||
"got it, thanks",
|
||||
"good morning",
|
||||
"remind me later", # no reasoning keyword, short
|
||||
],
|
||||
)
|
||||
def test_chatty_messages_off(self, text):
|
||||
assert _should_think(text, think_requested=False) is False
|
||||
@@ -59,7 +59,6 @@ def test_detect_forecast_changes_rain_added():
|
||||
def test_parse_weather_card_data_returns_none_when_stale():
|
||||
"""Should return None when cache is older than 24 hours."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
cache = MagicMock()
|
||||
@@ -71,7 +70,6 @@ def test_parse_weather_card_data_returns_none_when_stale():
|
||||
def test_parse_weather_card_data_returns_card_schema():
|
||||
"""Should return the correct metadata.weather schema for fresh cache."""
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from unittest.mock import MagicMock
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
today = date.today().isoformat()
|
||||
@@ -103,3 +101,66 @@ def test_parse_weather_card_data_returns_card_schema():
|
||||
assert card["yesterday_low"] == 9
|
||||
assert len(card["forecast"]) >= 1
|
||||
assert card["location"] == "Berlin, DE"
|
||||
assert card["precip_summary"] is None
|
||||
|
||||
|
||||
def test_summarize_precip_dry():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
assert _summarize_precip([(h, 10) for h in range(24)]) is None
|
||||
|
||||
|
||||
def test_summarize_precip_all_day():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
result = _summarize_precip([(h, 60) for h in range(24)])
|
||||
assert result is not None
|
||||
assert "all day" in result.lower()
|
||||
|
||||
|
||||
def test_summarize_precip_window():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
hourly = [(h, 5) for h in range(24)]
|
||||
for h in range(14, 18):
|
||||
hourly[h] = (h, 65)
|
||||
result = _summarize_precip(hourly)
|
||||
assert result is not None
|
||||
assert "2 PM" in result
|
||||
assert "65%" in result
|
||||
|
||||
|
||||
def test_parse_weather_card_includes_hourly_precip_summary():
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from fabledassistant.services.weather import parse_weather_card_data
|
||||
|
||||
today = date.today().isoformat()
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
||||
|
||||
hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
|
||||
hourly_probs = [5] * 24
|
||||
for h in range(14, 18):
|
||||
hourly_probs[h] = 70
|
||||
|
||||
cache = MagicMock()
|
||||
cache.fetched_at = datetime.now(timezone.utc)
|
||||
cache.location_label = "Berlin, DE"
|
||||
cache.forecast_json = {
|
||||
"current_weather": {"temperature": 12.0, "weathercode": 61},
|
||||
"daily": {
|
||||
"time": [yesterday, today, tomorrow],
|
||||
"temperature_2m_max": [14.0, 16.0, 18.0],
|
||||
"temperature_2m_min": [9.0, 8.0, 10.0],
|
||||
"precipitation_sum": [0.0, 5.0, 1.5],
|
||||
"precipitation_probability_max": [0, 70, 30],
|
||||
"weathercode": [1, 61, 61],
|
||||
"windspeed_10m_max": [10.0, 12.0, 8.0],
|
||||
},
|
||||
"hourly": {
|
||||
"time": hourly_times,
|
||||
"precipitation_probability": hourly_probs,
|
||||
},
|
||||
}
|
||||
|
||||
card = parse_weather_card_data(cache)
|
||||
assert card is not None
|
||||
assert card["precip_summary"] is not None
|
||||
assert "2 PM" in card["precip_summary"]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for the Wikipedia service module."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fabledassistant.services.wikipedia import wiki_summary, wiki_search
|
||||
|
||||
|
||||
def _make_mock_response(json_data: dict, status_code: int = 200) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.json.return_value = json_data
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_extract():
|
||||
"""Successful lookup returns a dict with title, extract, and url."""
|
||||
payload = {
|
||||
"type": "standard",
|
||||
"title": "Python (programming language)",
|
||||
"extract": "Python is a high-level programming language.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python programming language")
|
||||
|
||||
assert result is not None
|
||||
assert result["title"] == "Python (programming language)"
|
||||
assert "Python" in result["extract"]
|
||||
assert result["url"].startswith("https://")
|
||||
assert result["thumbnail_url"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_includes_thumbnail():
|
||||
"""Thumbnail URL is extracted when the article has an image."""
|
||||
payload = {
|
||||
"type": "standard",
|
||||
"title": "Hedgehog",
|
||||
"extract": "Hedgehogs are spiny mammals.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Hedgehog"}},
|
||||
"thumbnail": {"source": "https://upload.wikimedia.org/foo-320px.jpg"},
|
||||
"originalimage": {"source": "https://upload.wikimedia.org/foo.jpg"},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Hedgehog")
|
||||
|
||||
assert result is not None
|
||||
assert result["thumbnail_url"] == "https://upload.wikimedia.org/foo-320px.jpg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_404():
|
||||
"""HTTP error (e.g. 404) causes wiki_summary to return None."""
|
||||
import httpx
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"404", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("NonExistentPageXYZ123")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
||||
"""Disambiguation pages cause wiki_summary to return None."""
|
||||
payload = {
|
||||
"type": "disambiguation",
|
||||
"title": "Mercury",
|
||||
"extract": "Mercury may refer to:",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Mercury"}},
|
||||
}
|
||||
mock_resp = _make_mock_response(payload)
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Mercury")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_results():
|
||||
"""wiki_search returns a list of result dicts for a successful query."""
|
||||
search_payload = {
|
||||
"query": {
|
||||
"search": [
|
||||
{"title": "Quantum mechanics"},
|
||||
{"title": "Quantum field theory"},
|
||||
]
|
||||
}
|
||||
}
|
||||
summary_payloads = [
|
||||
{
|
||||
"type": "standard",
|
||||
"title": "Quantum mechanics",
|
||||
"extract": "Quantum mechanics is a fundamental theory in physics.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_mechanics"}},
|
||||
},
|
||||
{
|
||||
"type": "standard",
|
||||
"title": "Quantum field theory",
|
||||
"extract": "Quantum field theory is a framework in theoretical physics.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_field_theory"}},
|
||||
},
|
||||
]
|
||||
|
||||
search_resp = _make_mock_response(search_payload)
|
||||
summary_resp_1 = _make_mock_response(summary_payloads[0])
|
||||
summary_resp_2 = _make_mock_response(summary_payloads[1])
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=[search_resp, summary_resp_1, summary_resp_2])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("quantum physics", limit=2)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["title"] == "Quantum mechanics"
|
||||
assert "quantum" in results[0]["extract"].lower()
|
||||
assert results[1]["title"] == "Quantum field theory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_empty_on_failure():
|
||||
"""wiki_search returns an empty list when the network request fails."""
|
||||
import httpx
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("anything")
|
||||
|
||||
assert results == []
|
||||
Reference in New Issue
Block a user