Compare commits
39 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 |
@@ -68,19 +68,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# Cache node_modules directly (not the npm download cache).
|
- name: Cache 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
|
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: frontend/node_modules
|
path: ~/.npm
|
||||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||||
|
restore-keys: npm-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.npm-cache.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
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/interaction": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"@fullcalendar/vue3": "^6.1.20",
|
"@fullcalendar/vue3": "^6.1.20",
|
||||||
|
"@ricky0123/vad-web": "^0.0.30",
|
||||||
"@tiptap/core": "^3.0.0",
|
"@tiptap/core": "^3.0.0",
|
||||||
"@tiptap/extension-link": "^3.0.0",
|
"@tiptap/extension-link": "^3.0.0",
|
||||||
"@tiptap/extension-list": "^3.0.0",
|
"@tiptap/extension-list": "^3.0.0",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"typescript": "~5.9.0",
|
"typescript": "~5.9.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
|
"vite-plugin-static-copy": "^4.0.1",
|
||||||
"vue-tsc": "^3.0.0"
|
"vue-tsc": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AppHeader from "@/components/AppHeader.vue";
|
|||||||
import ToastNotification from "@/components/ToastNotification.vue";
|
import ToastNotification from "@/components/ToastNotification.vue";
|
||||||
import { useTheme } from "@/composables/useTheme";
|
import { useTheme } from "@/composables/useTheme";
|
||||||
import { useShortcuts } from "@/composables/useShortcuts";
|
import { useShortcuts } from "@/composables/useShortcuts";
|
||||||
|
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
@@ -12,6 +13,9 @@ import { apiGet, apiPut } from "@/api/client";
|
|||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
|
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
|
||||||
|
scheduleVadPreload();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appVersion = ref("dev");
|
const appVersion = ref("dev");
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTheme } from "@/composables/useTheme";
|
|||||||
import { useShortcuts } from "@/composables/useShortcuts";
|
import { useShortcuts } from "@/composables/useShortcuts";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
import NotificationBell from "@/components/NotificationBell.vue";
|
import NotificationBell from "@/components/NotificationBell.vue";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const { theme, toggleTheme } = useTheme();
|
|||||||
const { toggleShortcuts } = useShortcuts();
|
const { toggleShortcuts } = useShortcuts();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
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="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||||
<router-link to="/briefing" class="nav-link">Briefing</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="/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>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +130,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/briefing" class="nav-link">Briefing</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="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</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>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { apiGet, transcribeAudio } from '@/api/client'
|
||||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
import { useVad } from '@/composables/useVad'
|
||||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||||
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
||||||
import { useListenMode } from '@/composables/useListenMode'
|
import { useListenMode } from '@/composables/useListenMode'
|
||||||
@@ -138,29 +138,50 @@ function removeAttachedNote() {
|
|||||||
attachedNote.value = null
|
attachedNote.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Voice (click-to-toggle + silence detection) ─────────────────────────────
|
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
|
||||||
const transcribingVoice = ref(false)
|
const transcribingVoice = ref(false)
|
||||||
const recorder = useVoiceRecorder()
|
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
|
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||||
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for
|
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
|
||||||
// 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.
|
|
||||||
const micGlowStyle = computed(() => {
|
const micGlowStyle = computed(() => {
|
||||||
if (!recorder.recording.value) return { display: 'none' }
|
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 {
|
return {
|
||||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||||
opacity: String(0.45 + pulse * 0.4),
|
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() {
|
async function toggleVoice() {
|
||||||
if (transcribingVoice.value) return
|
if (transcribingVoice.value) return
|
||||||
if (recorder.recording.value) {
|
if (recorder.recording.value) {
|
||||||
await stopRecording()
|
await stopRecording(true)
|
||||||
} else {
|
} else {
|
||||||
await startRecording()
|
await startRecording()
|
||||||
}
|
}
|
||||||
@@ -172,22 +193,32 @@ async function startRecording() {
|
|||||||
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
vadSawSpeech.value = false
|
||||||
await recorder.startRecording()
|
await recorder.startRecording()
|
||||||
if (recorder.error.value) {
|
if (recorder.error.value) {
|
||||||
useToastStore().show(recorder.error.value, 'error')
|
useToastStore().show(recorder.error.value, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recorder.stream.value) {
|
if (recorder.stream.value) {
|
||||||
silenceDetector.start(recorder.stream.value, () => stopRecording())
|
await vad.start(recorder.stream.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording() {
|
async function stopRecording(manual: boolean) {
|
||||||
silenceDetector.stop()
|
if (manual) {
|
||||||
|
await vad.stopAndCheck()
|
||||||
|
} else {
|
||||||
|
await vad.stop()
|
||||||
|
}
|
||||||
if (!recorder.recording.value) return
|
if (!recorder.recording.value) return
|
||||||
transcribingVoice.value = true
|
transcribingVoice.value = true
|
||||||
try {
|
try {
|
||||||
const blob = await recorder.stopRecording()
|
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
|
// Pass last assistant message as context to reduce STT mishearings
|
||||||
const msgs = store.currentConversation?.messages ?? []
|
const msgs = store.currentConversation?.messages ?? []
|
||||||
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
||||||
|
|||||||
@@ -665,7 +665,6 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
|||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding: 0.5rem 1rem 0.75rem;
|
padding: 0.5rem 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface ForecastDay {
|
|||||||
precip_probability: number | null
|
precip_probability: number | null
|
||||||
precip_mm: number | null
|
precip_mm: number | null
|
||||||
windspeed_max: number
|
windspeed_max: number
|
||||||
|
precip_summary?: string
|
||||||
|
precip_peak_hour?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
@@ -21,6 +23,7 @@ interface WeatherData {
|
|||||||
yesterday_high: number | null
|
yesterday_high: number | null
|
||||||
yesterday_low: number | null
|
yesterday_low: number | null
|
||||||
wind_unit?: string
|
wind_unit?: string
|
||||||
|
precip_summary?: string | null
|
||||||
forecast: ForecastDay[]
|
forecast: ForecastDay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function hasPrecip(day: ForecastDay): boolean {
|
||||||
|
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||||
|
(day.precip_mm != null && day.precip_mm > 0)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,22 +93,39 @@ const fetchedAtLabel = computed(() => {
|
|||||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="weather-forecast" v-if="weather.forecast.length">
|
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||||
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
|
💧 {{ weather.precip_summary }}
|
||||||
<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>
|
</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>
|
||||||
<div v-else class="weather-card weather-unavailable">
|
<div v-else class="weather-card weather-unavailable">
|
||||||
Weather data unavailable — will retry at next slot.
|
Weather data unavailable — will retry at next slot.
|
||||||
@@ -115,6 +140,7 @@ const fetchedAtLabel = computed(() => {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-header {
|
.weather-header {
|
||||||
@@ -142,12 +168,12 @@ const fetchedAtLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weather-icon {
|
.weather-icon {
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-temp {
|
.weather-temp {
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -159,7 +185,7 @@ const fetchedAtLabel = computed(() => {
|
|||||||
|
|
||||||
.weather-today {
|
.weather-today {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,48 +194,59 @@ const fetchedAtLabel = computed(() => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-forecast {
|
.weather-precip-summary {
|
||||||
display: flex;
|
color: var(--color-text-secondary);
|
||||||
gap: 0.75rem;
|
font-size: 0.83rem;
|
||||||
overflow-x: auto;
|
margin-bottom: 0.75rem;
|
||||||
padding-top: 0.75rem;
|
padding: 0.35rem 0.5rem;
|
||||||
border-top: 1px solid var(--color-border);
|
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 {
|
.weather-forecast {
|
||||||
display: flex;
|
width: 100%;
|
||||||
flex-direction: column;
|
border-collapse: collapse;
|
||||||
align-items: center;
|
margin-top: 0.75rem;
|
||||||
gap: 0.25rem;
|
border-top: 1px solid var(--color-border);
|
||||||
min-width: 4.5rem;
|
|
||||||
font-size: 0.8rem;
|
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 {
|
.forecast-day-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecast-icon {
|
.forecast-icon {
|
||||||
font-size: 1.2rem;
|
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
||||||
line-height: 1;
|
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 {
|
.forecast-temps {
|
||||||
white-space: nowrap;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecast-precip,
|
.forecast-precip {
|
||||||
.forecast-wind {
|
text-align: right;
|
||||||
font-size: 0.72rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +254,23 @@ const fetchedAtLabel = computed(() => {
|
|||||||
opacity: 0.35;
|
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 {
|
.weather-unavailable {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
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 ── */
|
/* ── Left rail ── */
|
||||||
.note-rail {
|
.note-rail {
|
||||||
width: 155px;
|
width: 200px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--color-border);
|
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-header {
|
.rail-header {
|
||||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
border-left: 2px solid transparent;
|
border-right: 2px solid transparent;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||||
.note-row.active {
|
.note-row.active {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
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 {
|
.note-row-main {
|
||||||
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.note-title-row {
|
.note-title-row {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.9rem 1.1rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.note-title-input:focus { outline: none; }
|
.note-title-input:focus { outline: none; }
|
||||||
|
.note-title-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-row {
|
.tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||||
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.toolbar-row {
|
.toolbar-row {
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||||
flex-shrink: 0;
|
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 || ""
|
() => settings.value.default_model || ""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rssEnabled = computed(
|
||||||
|
() => settings.value.rss_enabled === "true"
|
||||||
|
);
|
||||||
|
|
||||||
// Voice status — checked once on login, refreshable from Settings
|
// Voice status — checked once on login, refreshable from Settings
|
||||||
const voiceEnabled = ref(false);
|
const voiceEnabled = ref(false);
|
||||||
const voiceSttReady = ref(false);
|
const voiceSttReady = ref(false);
|
||||||
@@ -62,6 +66,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
loading,
|
loading,
|
||||||
assistantName,
|
assistantName,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
|
rssEnabled,
|
||||||
voiceEnabled,
|
voiceEnabled,
|
||||||
voiceSttReady,
|
voiceSttReady,
|
||||||
voiceTtsReady,
|
voiceTtsReady,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import ChatPanel from '@/components/ChatPanel.vue'
|
import ChatPanel from '@/components/ChatPanel.vue'
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||||
@@ -15,7 +16,9 @@ import {
|
|||||||
postRssReaction,
|
postRssReaction,
|
||||||
deleteRssReaction,
|
deleteRssReaction,
|
||||||
getNewsItems,
|
getNewsItems,
|
||||||
|
listEvents,
|
||||||
type BriefingConversation,
|
type BriefingConversation,
|
||||||
|
type EventEntry,
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
import type { NewsItem } from '@/types/news'
|
import type { NewsItem } from '@/types/news'
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ interface WeatherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Setup wizard
|
// Setup wizard
|
||||||
const showWizard = ref(false)
|
const showWizard = ref(false)
|
||||||
@@ -56,8 +60,9 @@ const todayConvId = ref<number | null>(null)
|
|||||||
|
|
||||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||||
|
|
||||||
// Weather panel (left column)
|
// Weather panel
|
||||||
const weatherData = ref<WeatherData[]>([])
|
const weatherData = ref<WeatherData[]>([])
|
||||||
|
const selectedWeatherIdx = ref(0)
|
||||||
const tempUnit = ref<string>('C')
|
const tempUnit = ref<string>('C')
|
||||||
|
|
||||||
interface CurrentConditions {
|
interface CurrentConditions {
|
||||||
@@ -89,6 +94,66 @@ async function loadWeather() {
|
|||||||
} catch { /* silent */ }
|
} 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)
|
// News panel (right column)
|
||||||
const newsItems = ref<NewsItem[]>([])
|
const newsItems = ref<NewsItem[]>([])
|
||||||
|
|
||||||
@@ -112,6 +177,7 @@ async function loadAll() {
|
|||||||
loadWeather(),
|
loadWeather(),
|
||||||
loadNews(),
|
loadNews(),
|
||||||
loadCurrentConditions(),
|
loadCurrentConditions(),
|
||||||
|
loadEvents(),
|
||||||
])
|
])
|
||||||
conversations.value = convList
|
conversations.value = convList
|
||||||
if (today) {
|
if (today) {
|
||||||
@@ -261,22 +327,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Left column: Weather -->
|
<!-- Left column: Chat -->
|
||||||
<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 -->
|
|
||||||
<div class="briefing-center">
|
<div class="briefing-center">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
variant="full"
|
variant="full"
|
||||||
@@ -287,8 +338,57 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column: News -->
|
<!-- Right column: Weather + News -->
|
||||||
<div class="briefing-right">
|
<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-row">
|
||||||
<div class="panel-label">Today's News</div>
|
<div class="panel-label">Today's News</div>
|
||||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||||
@@ -333,6 +433,7 @@ onMounted(async () => {
|
|||||||
>💬</button>
|
>💬</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +449,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.briefing-shell {
|
.briefing-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2fr 1fr;
|
grid-template-columns: 1fr minmax(320px, 35%);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -421,27 +522,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
|
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.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) ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.briefing-center {
|
.briefing-center {
|
||||||
grid-column: 2;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -453,14 +537,160 @@ onMounted(async () => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
||||||
|
|
||||||
.briefing-right {
|
.briefing-right {
|
||||||
grid-column: 3;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
border-left: 1px solid var(--color-border);
|
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;
|
overflow-y: auto;
|
||||||
padding: 1rem;
|
padding: 0.75rem 1rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -581,29 +811,18 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.briefing-shell {
|
.briefing-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto 1fr auto;
|
grid-template-rows: 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;
|
|
||||||
}
|
}
|
||||||
.briefing-center {
|
.briefing-center {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 3;
|
grid-row: 2;
|
||||||
}
|
}
|
||||||
.briefing-right {
|
.briefing-right {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 4;
|
grid-row: 3;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
max-height: 260px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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() {
|
async function addFeed() {
|
||||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||||
addingFeed.value = true;
|
addingFeed.value = true;
|
||||||
@@ -2209,8 +2217,20 @@ function formatUserDate(iso: string): string {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS toggle -->
|
||||||
<section class="settings-section full-width">
|
<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 class="briefing-feeds-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>RSS Feeds</h2>
|
<h2>RSS Feeds</h2>
|
||||||
@@ -2252,7 +2272,7 @@ function formatUserDate(iso: string): string {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- News Preferences -->
|
<!-- News Preferences -->
|
||||||
<section class="settings-section full-width">
|
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||||
<h2>News Preferences</h2>
|
<h2>News Preferences</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
Tell the briefing what topics you care about. Topics are matched against
|
Tell the briefing what topics you care about. Topics are matched against
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute } from "vue-router";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { useChatStore } from "@/stores/chat";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import ChatPanel from "@/components/ChatPanel.vue";
|
|
||||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||||
|
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const chatStore = useChatStore();
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
const projectId = computed(() => Number(route.params.projectId));
|
const projectId = computed(() => Number(route.params.projectId));
|
||||||
@@ -21,142 +19,62 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = ref<Project | null>(null);
|
const project = ref<Project | null>(null);
|
||||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
|
||||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||||
const activeNoteId = ref<number | null>(null);
|
const activeNoteId = ref<number | null>(null);
|
||||||
let workspaceConvId: number | null = null;
|
|
||||||
let isNewConv = false;
|
|
||||||
|
|
||||||
function _storageKey(pid: number) {
|
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
|
||||||
return `workspace_conv_${pid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Panel collapse state — persisted per project
|
|
||||||
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
||||||
|
|
||||||
function _loadPanelState(pid: number) {
|
function _loadPanelState(pid: number) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(_panelKey(pid));
|
const raw = localStorage.getItem(_panelKey(pid));
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const saved = JSON.parse(raw);
|
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
|
||||||
// Ensure at least one panel is open after restore
|
const tasks = saved.tasks ?? true;
|
||||||
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
|
const notes = saved.notes ?? true;
|
||||||
return saved as { tasks: boolean; chat: boolean; notes: boolean };
|
// Guard: at least one panel must be open
|
||||||
|
if (!tasks && !notes) return { tasks: true, notes: true };
|
||||||
|
return { tasks, notes };
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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(() => {
|
const gridColumns = computed(() => {
|
||||||
return [
|
return [
|
||||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
|
||||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
|
||||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
|
||||||
].join(" ");
|
].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) {
|
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||||
const open = 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;
|
if (open[panel] && openCount <= 1) return;
|
||||||
panelOpen.value[panel] = !panelOpen.value[panel];
|
panelOpen.value[panel] = !panelOpen.value[panel];
|
||||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefill(text: string) {
|
function onNoteChanged(noteId: number) {
|
||||||
chatPanelRef.value?.prefill(text);
|
activeNoteId.value = noteId;
|
||||||
|
noteEditorRef.value?.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskChanged() {
|
||||||
|
taskPanelRef.value?.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Restore panel state
|
|
||||||
panelOpen.value = _loadPanelState(projectId.value);
|
panelOpen.value = _loadPanelState(projectId.value);
|
||||||
|
|
||||||
// Load project info
|
|
||||||
try {
|
try {
|
||||||
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to load project", "error");
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -176,13 +94,6 @@ onUnmounted(async () => {
|
|||||||
>
|
>
|
||||||
Tasks
|
Tasks
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
:class="['panel-toggle', { active: panelOpen.chat }]"
|
|
||||||
title="Toggle Chat panel"
|
|
||||||
@click="togglePanel('chat')"
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
:class="['panel-toggle', { active: panelOpen.notes }]"
|
:class="['panel-toggle', { active: panelOpen.notes }]"
|
||||||
title="Toggle Notes panel"
|
title="Toggle Notes panel"
|
||||||
@@ -193,9 +104,8 @@ onUnmounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Three-panel body -->
|
<!-- Two-panel body -->
|
||||||
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
||||||
|
|
||||||
<!-- Left: Tasks -->
|
<!-- Left: Tasks -->
|
||||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||||
<Transition name="panel-fade">
|
<Transition name="panel-fade">
|
||||||
@@ -209,35 +119,8 @@ onUnmounted(async () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</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 -->
|
<!-- 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">
|
<Transition name="panel-fade">
|
||||||
<div v-if="panelOpen.notes" class="panel-inner">
|
<div v-if="panelOpen.notes" class="panel-inner">
|
||||||
<WorkspaceNoteEditor
|
<WorkspaceNoteEditor
|
||||||
@@ -249,13 +132,21 @@ onUnmounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating chat widget -->
|
||||||
|
<WorkspaceChatWidget
|
||||||
|
v-if="project"
|
||||||
|
:project-id="projectId"
|
||||||
|
@note-changed="onNoteChanged"
|
||||||
|
@task-changed="onTaskChanged"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.workspace-root {
|
.workspace-root {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -263,7 +154,6 @@ onUnmounted(async () => {
|
|||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.ws-header {
|
.ws-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -318,7 +208,6 @@ onUnmounted(async () => {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Three-panel layout */
|
|
||||||
.ws-body {
|
.ws-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -331,6 +220,10 @@ onUnmounted(async () => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-panel-notes {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.panel-inner {
|
.panel-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -345,60 +238,4 @@ onUnmounted(async () => {
|
|||||||
.panel-fade-leave-to {
|
.panel-fade-leave-to {
|
||||||
opacity: 0;
|
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>
|
</style>
|
||||||
|
|||||||
+31
-1
@@ -1,9 +1,39 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
|
|||||||
@@ -143,10 +143,14 @@ def create_app() -> Quart:
|
|||||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
"default-src 'self'; "
|
||||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
|
||||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||||
"base-uri 'self'; worker-src 'self';"
|
"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
|
return response
|
||||||
|
|||||||
@@ -54,11 +54,17 @@ async def put_config():
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def _rss_enabled() -> bool:
|
||||||
|
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@briefing_bp.route("/feeds", methods=["GET"])
|
@briefing_bp.route("/feeds", methods=["GET"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def list_feeds():
|
async def list_feeds():
|
||||||
|
if not await _rss_enabled():
|
||||||
|
return jsonify([])
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
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"])
|
@briefing_bp.route("/feeds", methods=["POST"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def add_feed():
|
async def add_feed():
|
||||||
|
if not await _rss_enabled():
|
||||||
|
return jsonify({"error": "RSS is disabled"}), 403
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
url = (data.get("url") or "").strip()
|
url = (data.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
@@ -120,6 +128,8 @@ async def delete_feed(feed_id: int):
|
|||||||
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def refresh_feeds():
|
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)
|
results = await rss_svc.refresh_all_feeds(g.user.id)
|
||||||
total_new = sum(results.values())
|
total_new = sum(results.values())
|
||||||
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
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"])
|
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def recent_items():
|
async def recent_items():
|
||||||
|
if not await _rss_enabled():
|
||||||
|
return jsonify({"items": []})
|
||||||
limit = min(int(request.args.get("limit", 20)), 100)
|
limit = min(int(request.args.get("limit", 20)), 100)
|
||||||
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
||||||
return jsonify({"items": items})
|
return jsonify({"items": items})
|
||||||
@@ -155,6 +167,7 @@ async def get_weather():
|
|||||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@briefing_bp.route("/weather/current", methods=["GET"])
|
@briefing_bp.route("/weather/current", methods=["GET"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def get_current_weather():
|
async def get_current_weather():
|
||||||
@@ -213,11 +226,14 @@ async def refresh_weather():
|
|||||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||||
try:
|
try:
|
||||||
config = json.loads(raw) if isinstance(raw, str) else {}
|
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:
|
except Exception:
|
||||||
config = {}
|
config = {}
|
||||||
|
temp_unit = "C"
|
||||||
|
|
||||||
locations = config.get("locations", {})
|
locations = config.get("locations", {})
|
||||||
refreshed = []
|
|
||||||
for key, loc in locations.items():
|
for key, loc in locations.items():
|
||||||
if not loc.get("lat") or not loc.get("lon"):
|
if not loc.get("lat") or not loc.get("lon"):
|
||||||
continue
|
continue
|
||||||
@@ -229,11 +245,15 @@ async def refresh_weather():
|
|||||||
lat=loc["lat"],
|
lat=loc["lat"],
|
||||||
lon=loc["lon"],
|
lon=loc["lon"],
|
||||||
)
|
)
|
||||||
refreshed.append(key)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
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 ─────────────────────────────────────────────────────
|
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
||||||
@@ -454,6 +474,9 @@ async def list_news():
|
|||||||
offset — pagination offset (default 0)
|
offset — pagination offset (default 0)
|
||||||
feed_id — optional integer filter by feed
|
feed_id — optional integer filter by feed
|
||||||
"""
|
"""
|
||||||
|
if not await _rss_enabled():
|
||||||
|
return jsonify({"items": [], "total": 0})
|
||||||
|
|
||||||
from sqlalchemy import text as _text
|
from sqlalchemy import text as _text
|
||||||
|
|
||||||
days = min(int(request.args.get("days", 2)), 90)
|
days = min(int(request.args.get("days", 2)), 90)
|
||||||
|
|||||||
@@ -20,18 +20,26 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
|||||||
# ── External data gather ──────────────────────────────────────────────────────
|
# ── External data gather ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _gather_external(user_id: int) -> dict:
|
async def _gather_external(user_id: int) -> dict:
|
||||||
"""Collect RSS items and weather."""
|
"""Collect RSS items (when enabled) and weather."""
|
||||||
from fabledassistant.services.rss import get_recent_items
|
|
||||||
from fabledassistant.services.weather import get_cached_weather
|
from fabledassistant.services.weather import get_cached_weather
|
||||||
|
|
||||||
rss_items, weather = await asyncio.gather(
|
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||||
get_recent_items(user_id, limit=20),
|
rss_items: list = []
|
||||||
get_cached_weather(user_id),
|
if rss_on:
|
||||||
return_exceptions=True,
|
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 {
|
return {
|
||||||
"rss_items": rss_items if not isinstance(rss_items, Exception) else [],
|
"rss_items": rss_items,
|
||||||
"weather": weather if not isinstance(weather, Exception) else [],
|
"weather": weather,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -371,7 +379,11 @@ async def run_compilation(
|
|||||||
if item.get("id")
|
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(
|
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
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
|
# Refresh external data first
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
from fabledassistant.services.rss import refresh_all_feeds
|
|
||||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
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
|
from fabledassistant.services import weather as wx
|
||||||
for key, loc in config.get("locations", {}).items():
|
for key, loc in config.get("locations", {}).items():
|
||||||
if loc.get("lat") and loc.get("lon"):
|
if loc.get("lat") and loc.get("lon"):
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ _TOOL_LABELS: dict[str, str] = {
|
|||||||
"update_event": "Updating calendar event",
|
"update_event": "Updating calendar event",
|
||||||
"delete_event": "Removing calendar event",
|
"delete_event": "Removing calendar event",
|
||||||
"list_calendars": "Listing calendars",
|
"list_calendars": "Listing calendars",
|
||||||
"search_web": "Searching the web",
|
"lookup": "Looking up information",
|
||||||
"research_topic": "Researching topic",
|
"research_topic": "Researching topic",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -604,8 +604,9 @@ async def build_context(
|
|||||||
"Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
|
"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.")
|
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():
|
if Config.searxng_enabled():
|
||||||
actions.extend(["search_web", "research_topic", "search_images"])
|
actions.extend(["research_topic", "search_images"])
|
||||||
tool_lines.append(f"Available actions: {', '.join(actions)}.")
|
tool_lines.append(f"Available actions: {', '.join(actions)}.")
|
||||||
tool_lines.append(
|
tool_lines.append(
|
||||||
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
|
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
|
||||||
@@ -810,6 +811,8 @@ async def build_context(
|
|||||||
"score": round(score, 2),
|
"score": round(score, 2),
|
||||||
})
|
})
|
||||||
user_context_parts.append(
|
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"
|
"--- Relevant Notes ---\n"
|
||||||
+ "\n\n".join(snippets)
|
+ "\n\n".join(snippets)
|
||||||
+ "\n--- End Relevant Notes ---"
|
+ "\n--- End Relevant Notes ---"
|
||||||
@@ -850,33 +853,6 @@ async def build_context(
|
|||||||
+ "\n--- End Included Notes ---"
|
+ "\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
|
# URL content fetched from links in the user message
|
||||||
urls = _find_urls(user_message)
|
urls = _find_urls(user_message)
|
||||||
for url in urls[:2]:
|
for url in urls[:2]:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import httpx
|
|||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
|
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
|
||||||
from fabledassistant.services.notes import create_note, update_note
|
from fabledassistant.services.notes import create_note, update_note
|
||||||
|
from fabledassistant.services.wikipedia import wiki_search
|
||||||
from fabledassistant.models.note import Note
|
from fabledassistant.models.note import Note
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -205,7 +206,7 @@ async def run_research_pipeline(
|
|||||||
queries = await _generate_sub_queries(topic, model)
|
queries = await _generate_sub_queries(topic, model)
|
||||||
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
|
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]]:
|
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||||
if i > 0:
|
if i > 0:
|
||||||
await asyncio.sleep(0.2 * i)
|
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))
|
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||||
return query, 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)]
|
*[_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
|
# Deduplicate URLs across all queries
|
||||||
seen_urls: set[str] = set()
|
seen_urls: set[str] = set()
|
||||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
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 query, results in search_results:
|
||||||
for result in results[:PAGES_PER_QUERY]:
|
for result in results[:PAGES_PER_QUERY]:
|
||||||
url = result.get("url", "")
|
url = result.get("url", "")
|
||||||
@@ -228,7 +238,21 @@ async def run_research_pipeline(
|
|||||||
seen_urls.add(url)
|
seen_urls.add(url)
|
||||||
url_tasks.append((url, result, query))
|
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:
|
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||||
title = result.get("title", url)
|
title = result.get("title", url)
|
||||||
_status(f"Reading: {title[:60]}...")
|
_status(f"Reading: {title[:60]}...")
|
||||||
@@ -241,10 +265,12 @@ async def run_research_pipeline(
|
|||||||
"content": content,
|
"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]
|
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||||
))
|
))
|
||||||
|
|
||||||
|
all_sources = wiki_sources + fetched_sources
|
||||||
|
|
||||||
if not all_sources:
|
if not all_sources:
|
||||||
raise ValueError(f"No results found for '{topic}'")
|
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)
|
return await is_caldav_configured(user_id)
|
||||||
if requires == "searxng":
|
if requires == "searxng":
|
||||||
return Config.searxng_enabled()
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
|||||||
},
|
},
|
||||||
read_only=True,
|
read_only=True,
|
||||||
briefing=True,
|
briefing=True,
|
||||||
|
requires="rss",
|
||||||
)
|
)
|
||||||
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
|
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
|
||||||
from fabledassistant.services.rss import get_recent_items
|
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."},
|
"category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."},
|
||||||
},
|
},
|
||||||
required=["url"],
|
required=["url"],
|
||||||
|
requires="rss",
|
||||||
)
|
)
|
||||||
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
|
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
|
||||||
import asyncio as _asyncio
|
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. "
|
"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, "
|
"Use when the user shares a URL and wants you to read it, "
|
||||||
"or to get the full content of a linked page. "
|
"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={
|
parameters={
|
||||||
"url": {"type": "string", "description": "The URL to fetch and read"},
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,29 +10,107 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="search_web",
|
name="lookup",
|
||||||
description=(
|
description=(
|
||||||
"Quick web lookup — returns search result snippets for factual questions, current events, "
|
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
||||||
"definitions, or version numbers. No note is saved. Use research_topic when the user wants "
|
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
||||||
"a comprehensive written report saved as a note."
|
"'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={
|
parameters={
|
||||||
"query": {"type": "string", "description": "The search query"},
|
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||||
},
|
},
|
||||||
required=["query"],
|
required=["query"],
|
||||||
requires="searxng",
|
|
||||||
)
|
)
|
||||||
async def search_web_tool(*, user_id, arguments, **_ctx):
|
async def lookup_tool(*, user_id, arguments, **_ctx):
|
||||||
from fabledassistant.services.research import _search_searxng
|
import asyncio
|
||||||
|
|
||||||
query = arguments.get("query", "")
|
from fabledassistant.config import Config
|
||||||
results = await _search_searxng(query)
|
from fabledassistant.services.wikipedia import wiki_summary
|
||||||
if not results:
|
|
||||||
return {"success": False, "error": f"No results found for '{query}'"}
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "web_search",
|
"type": "lookup",
|
||||||
"data": {"query": query, "results": results, "count": len(results)},
|
"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 "
|
"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', "
|
"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. "
|
"'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={
|
parameters={
|
||||||
"topic": {"type": "string", "description": "The topic or question to research"},
|
"topic": {"type": "string", "description": "The topic or question to research"},
|
||||||
@@ -64,7 +142,7 @@ async def research_topic_tool(*, user_id, arguments, **_ctx):
|
|||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="search_images",
|
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={
|
parameters={
|
||||||
"query": {"type": "string", "description": "The image search query"},
|
"query": {"type": "string", "description": "The image search query"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ OPEN_METEO_DAILY = (
|
|||||||
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||||
"precipitation_probability_max,weathercode,windspeed_10m_max"
|
"precipitation_probability_max,weathercode,windspeed_10m_max"
|
||||||
)
|
)
|
||||||
|
OPEN_METEO_HOURLY = "precipitation_probability"
|
||||||
|
|
||||||
# WMO weather code → description (subset; covers the most common codes)
|
# WMO weather code → description (subset; covers the most common codes)
|
||||||
_WMO_CODES: dict[int, str] = {
|
_WMO_CODES: dict[int, str] = {
|
||||||
@@ -93,6 +94,55 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
|
|||||||
return changes
|
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(
|
def parse_weather_card_data(
|
||||||
cache_row,
|
cache_row,
|
||||||
temp_unit: str = "C",
|
temp_unit: str = "C",
|
||||||
@@ -138,6 +188,30 @@ def parse_weather_card_data(
|
|||||||
|
|
||||||
wind_unit = "mph" if imperial else "km/h"
|
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 {
|
return {
|
||||||
"location": getattr(cache_row, "location_label", ""),
|
"location": getattr(cache_row, "location_label", ""),
|
||||||
"fetched_at": cache_row.fetched_at.isoformat(),
|
"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_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,
|
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
|
||||||
"wind_unit": wind_unit,
|
"wind_unit": wind_unit,
|
||||||
"forecast": [
|
"precip_summary": today_precip_summary,
|
||||||
{
|
"forecast": [_forecast_day(d) for d in future_days],
|
||||||
"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
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
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:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
resp = await client.get(OPEN_METEO_URL, params={
|
resp = await client.get(OPEN_METEO_URL, params={
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"longitude": lon,
|
||||||
"daily": OPEN_METEO_DAILY,
|
"daily": OPEN_METEO_DAILY,
|
||||||
|
"hourly": OPEN_METEO_HOURLY,
|
||||||
"current_weather": "true",
|
"current_weather": "true",
|
||||||
"past_days": 1,
|
"past_days": 1,
|
||||||
"timezone": "auto",
|
"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
|
@pytest.mark.asyncio
|
||||||
async def test_tools_calendar_always_available():
|
async def test_tools_calendar_always_available():
|
||||||
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
"""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_configured.return_value = False
|
||||||
|
mock_setting.return_value = "false"
|
||||||
from fabledassistant.services.tools import get_tools_for_user
|
from fabledassistant.services.tools import get_tools_for_user
|
||||||
tools = await get_tools_for_user(user_id=1)
|
tools = await get_tools_for_user(user_id=1)
|
||||||
tool_names = {t["function"]["name"] for t in tools}
|
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
|
n.title = title
|
||||||
return n
|
return n
|
||||||
|
|
||||||
mock_update = AsyncMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
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._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"), \
|
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")
|
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||||
|
|
||||||
assert result.id == 77
|
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():
|
def test_parse_weather_card_data_returns_none_when_stale():
|
||||||
"""Should return None when cache is older than 24 hours."""
|
"""Should return None when cache is older than 24 hours."""
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from fabledassistant.services.weather import parse_weather_card_data
|
from fabledassistant.services.weather import parse_weather_card_data
|
||||||
|
|
||||||
cache = MagicMock()
|
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():
|
def test_parse_weather_card_data_returns_card_schema():
|
||||||
"""Should return the correct metadata.weather schema for fresh cache."""
|
"""Should return the correct metadata.weather schema for fresh cache."""
|
||||||
from datetime import datetime, timezone, timedelta, date
|
from datetime import datetime, timezone, timedelta, date
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from fabledassistant.services.weather import parse_weather_card_data
|
from fabledassistant.services.weather import parse_weather_card_data
|
||||||
|
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
@@ -103,3 +101,66 @@ def test_parse_weather_card_data_returns_card_schema():
|
|||||||
assert card["yesterday_low"] == 9
|
assert card["yesterday_low"] == 9
|
||||||
assert len(card["forecast"]) >= 1
|
assert len(card["forecast"]) >= 1
|
||||||
assert card["location"] == "Berlin, DE"
|
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