Add image search with local cache (Phase 23)
Images found via SearXNG are fetched server-side, stored on disk, and served from /api/images/<id> — the user's browser never contacts the original image host. Original URLs are preserved for citation. New files: - alembic/versions/0016_add_image_cache.py — image_cache table - src/fabledassistant/models/image_cache.py — SQLAlchemy model - src/fabledassistant/services/images.py — fetch/store/serve logic - src/fabledassistant/routes/images.py — GET /api/images/<id> Modified: - config.py: IMAGE_CACHE_DIR (/data/images), IMAGE_MAX_BYTES (5 MB) - research.py: _search_searxng_images() — SearXNG categories=images - tools.py: _IMAGE_TOOLS def + search_images branch in execute_tool - intent.py: search_images routing rule (explicit visual language only) - app.py: register images_bp - docker-compose.yml: image_cache named volume mounted at /data/images - ToolCallCard.vue: "image_search" label Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,52 @@ async def _search_searxng(query: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
async def _search_searxng_images(query: str) -> list[dict]:
|
||||
"""Search SearXNG image category and return [{img_src, page_url, title, source_domain}]."""
|
||||
url = Config.SEARXNG_URL.rstrip("/") + "/search"
|
||||
params = {"q": query, "format": "json", "categories": "images"}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
if resp.status_code == 429:
|
||||
retry_after = int(resp.headers.get("Retry-After", "5"))
|
||||
wait = min(retry_after, 10) * (attempt + 1)
|
||||
logger.warning(
|
||||
"SearXNG image 429 for '%s' (attempt %d/3), waiting %ds",
|
||||
query, attempt + 1, wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
out = []
|
||||
for r in data.get("results", []):
|
||||
img_src = r.get("img_src") or r.get("thumbnail_src", "")
|
||||
if not img_src:
|
||||
continue
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
source_domain = urlparse(r.get("url", "")).netloc or ""
|
||||
except Exception:
|
||||
source_domain = ""
|
||||
out.append({
|
||||
"img_src": img_src,
|
||||
"page_url": r.get("url", ""),
|
||||
"title": r.get("title", ""),
|
||||
"source_domain": source_domain,
|
||||
})
|
||||
return out
|
||||
except httpx.HTTPStatusError:
|
||||
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
|
||||
return []
|
||||
except Exception:
|
||||
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
|
||||
return []
|
||||
logger.warning("SearXNG image search gave up after 3 attempts for '%s'", query)
|
||||
return []
|
||||
|
||||
|
||||
async def _synthesize_note(
|
||||
topic: str,
|
||||
sources: list[dict],
|
||||
|
||||
Reference in New Issue
Block a user