Compare commits
41 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 |
@@ -68,19 +68,14 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } 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'
|
||||
@@ -138,29 +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 `silenceDetector.amplitude` (0..1 RMS, already amplified for
|
||||
// visibility). The button itself stays put so the mic icon is always
|
||||
// legible on top. 0.2 baseline breathes on silence; loud speech drives
|
||||
// the disc out to ~2.6× the button size with a softer radial fade.
|
||||
// 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(silenceDetector.amplitude.value, 1) * 0.8
|
||||
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()
|
||||
}
|
||||
@@ -172,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
|
||||
|
||||
@@ -665,7 +665,6 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.input-wrapper {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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,121 +0,0 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
/**
|
||||
* Absolute fallback silence threshold in dBFS, used during the first
|
||||
* moments before any real speech has been observed. Once the session peak
|
||||
* clears `dynamicArmDb` we switch to the dynamic threshold instead.
|
||||
*/
|
||||
fallbackThresholdDb?: number // default -45
|
||||
/** How many dB below the session peak counts as "silent" once armed. */
|
||||
dropFromPeakDb?: number // default 15
|
||||
/**
|
||||
* Session peak must reach this level (dBFS) before dynamic thresholding
|
||||
* kicks in. Until then the fallback threshold is used so we don't lock
|
||||
* onto a noise-floor peak.
|
||||
*/
|
||||
dynamicArmDb?: number // default -25
|
||||
/** How long to wait at the start of recording before running silence checks. */
|
||||
graceMs?: number // default 1500
|
||||
silenceDurationMs?: number // default 2000
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Mic silence detector + live amplitude signal.
|
||||
*
|
||||
* Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space,
|
||||
* then derives dBFS for the silence threshold. The previous implementation
|
||||
* ran RMS over `getByteFrequencyData` bytes — those bytes are already a
|
||||
* dB-scaled quantity, so taking their RMS and re-log'ing it produced
|
||||
* numbers that didn't line up with real dBFS and made any static threshold
|
||||
* unpredictable.
|
||||
*
|
||||
* Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||
* "silent" as "current level is at least [dropFromPeakDb] below peak."
|
||||
* This auto-calibrates to whatever mic and room the user is on. Until the
|
||||
* peak climbs above [dynamicArmDb] we fall back to a conservative static
|
||||
* threshold so a quiet room doesn't spin forever. A grace period at the
|
||||
* start of the recording gives the user time to begin speaking before
|
||||
* silence checks arm.
|
||||
*
|
||||
* `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet
|
||||
* speech still visibly moves UI that binds to it.
|
||||
*/
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
fallbackThresholdDb = -45,
|
||||
dropFromPeakDb = 15,
|
||||
dynamicArmDb = -25,
|
||||
graceMs = 1500,
|
||||
silenceDurationMs = 2000,
|
||||
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
|
||||
let peakDb = -100
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// the analyser returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 1024
|
||||
source.connect(analyser)
|
||||
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
peakDb = -100
|
||||
|
||||
intervalId = 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)
|
||||
|
||||
const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100
|
||||
if (db > peakDb) peakDb = db
|
||||
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed < graceMs) return
|
||||
|
||||
const threshold =
|
||||
peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb
|
||||
|
||||
if (db < threshold) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && elapsed >= 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
|
||||
peakDb = -100
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -130,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -587,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)."
|
||||
@@ -677,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:
|
||||
@@ -802,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 ---"
|
||||
@@ -842,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
|
||||
|
||||
@@ -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