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:
2026-03-01 12:55:10 -05:00
parent 90afd3f131
commit efe0a15b8c
12 changed files with 392 additions and 1 deletions
+46
View File
@@ -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],