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
+63
View File
@@ -710,6 +710,33 @@ _RESEARCH_TOOLS = [
}
]
_IMAGE_TOOLS = [
{
"type": "function",
"function": {
"name": "search_images",
"description": (
"Search the web for images and display them inline in the response. "
"ONLY use this when the user explicitly asks to SEE, SHOW, VIEW, or DISPLAY "
"an image or photo — e.g. 'show me a picture of X', 'what does X look like', "
"'find a photo of X', 'display an image of X'. "
"Do NOT use for general descriptions, factual questions, or when the user just "
"wants information without visual content. Returns at most 2 images."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The image search query",
}
},
"required": ["query"],
},
},
}
]
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
@@ -719,6 +746,7 @@ async def get_tools_for_user(user_id: int) -> list[dict]:
if Config.searxng_enabled():
tools.extend(_SEARCH_TOOLS)
tools.extend(_RESEARCH_TOOLS)
tools.extend(_IMAGE_TOOLS)
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
@@ -1215,6 +1243,41 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {"topic": topic},
}
elif tool_name == "search_images":
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images
query = arguments.get("query", "")
if not query:
return {"success": False, "error": "query is required"}
raw_results = await _search_searxng_images(query)
if not raw_results:
return {"success": False, "error": f"No images found for '{query}'"}
# Fetch and cache up to 2 images
images = []
for r in raw_results[:2]:
img_url = r.get("img_src", "")
if not img_url:
continue
record = await fetch_and_store_image(
url=img_url,
title=r.get("title"),
source_domain=r.get("source_domain"),
)
if record:
images.append({
"local_url": f"/api/images/{record.id}",
"page_url": r.get("page_url", ""),
"source_domain": r.get("source_domain", ""),
"title": record.title or query,
})
if not images:
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
return {
"success": True,
"type": "image_search",
"data": {"query": query, "images": images},
}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}