Files
ai-agent/backend/artefacts.py
Gabriel Vidal 7c66eaf38f feat(ai-agent): Artefacts card — per-conversation generated-media gallery
Conversation details drawer gains an Artefacts card: a horizontal strip of
image/video thumbnails collected from the conversation's projects'/services'
.ai/artefacts/<date>/<sessionId>/ folders, opening the full-screen media
viewer seeded with the whole set.

- backend/artefacts.py: list a conversation's artefacts across its tagged
  projects/services + traversal-safe single-file resolution; wired into
  /api/conversation (artefacts field) and a new GET /api/artefact serve route.
- ImageWidget maps Read paths under <project|service>/.ai/artefacts/ to the
  new route (inline preview keeps working with the new convention);
  ScreenshotWidget recognizes both the new artefact output path and the
  legacy docs/screenshots one.
- scripts/backfill-artefacts.py: one-off migration that mines archived
  transcripts for docs/screenshots/ + data/ media mentions and copies the
  surviving files into the right project's artefact folder (400 files across
  120 past conversations imported).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:59:19 +02:00

110 lines
3.9 KiB
Python

"""Per-conversation generated artefacts (screenshots, renders, exports).
The CLAUDE.md convention (and the `screenshot` skill) write every generated
media file into the canonical folder of the project/service the conversation
works on, keyed by day and session:
<projectFolder>/.ai/artefacts/<YYYY-MM-DD>/<sessionId>/<file>
where <projectFolder> is either ~/projects/<slug> (mounted at PROJECTS_DIR) or
~/homelab/services/<slug> (mounted read-only at SERVICES_DIR). This module
lists a conversation's artefacts across its tagged projects/services (for the
detail drawer's Artefacts card) and resolves a single artefact path for the
/api/artefact serve route, refusing traversal on every user-supplied part.
"""
from __future__ import annotations
import os
from pathlib import Path
from urllib.parse import quote
import projects as projects_mod
import svc as svc_mod
ARTEFACTS_SUBDIR = Path(".ai") / "artefacts"
# Raster images + inline-safe video containers (SVG is excluded: it can carry
# scripts and these files are agent/user-produced bytes served on the app origin).
ARTEFACT_EXTS = {
".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp", ".avif",
".mp4", ".webm", ".mov", ".m4v", ".ogv",
}
_VIDEO_EXTS = {".mp4", ".webm", ".mov", ".m4v", ".ogv"}
def _entry(kind: str, slug: str) -> Path | None:
"""Resolve a project/service slug to its directory (traversal-safe)."""
if kind == "project":
return projects_mod._safe_entry(slug)
if kind == "service":
return svc_mod._safe_entry(slug)
return None
def _iso(ts: float) -> str:
import datetime as dt
return dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def list_for_conversation(sid: str, projects: list[str],
services: list[str]) -> list[dict]:
"""Every artefact any of the conversation's projects/services holds for
this session id, oldest first (the order they were produced in)."""
if not sid or os.sep in sid or sid.startswith("."):
return []
out: list[dict] = []
seen: set[Path] = set()
for kind, slugs in (("project", projects), ("service", services)):
for slug in slugs:
entry = _entry(kind, slug)
if not entry:
continue
base = entry / ARTEFACTS_SUBDIR
if not base.is_dir():
continue
for f in base.glob(f"*/{sid}/*"):
if not f.is_file() or f.suffix.lower() not in ARTEFACT_EXTS:
continue
rf = f.resolve()
if rf in seen:
continue
seen.add(rf)
try:
st = f.stat()
except OSError:
continue
rel = f"{f.parent.parent.name}/{sid}/{f.name}"
out.append({
"kind": kind,
"slug": slug,
"name": f.name,
"date": f.parent.parent.name,
"path": rel,
"url": (f"/api/artefact?kind={kind}&slug={quote(slug)}"
f"&path={quote(rel)}"),
"bytes": st.st_size,
"mtime": _iso(st.st_mtime),
"video": f.suffix.lower() in _VIDEO_EXTS,
})
out.sort(key=lambda a: a["mtime"])
return out
def artefact_path(kind: str, slug: str, rel: str) -> Path | None:
"""Resolve one artefact inside a project/service's .ai/artefacts tree."""
entry = _entry(kind, slug)
if not entry:
return None
rel_p = Path(rel.replace("\\", "/"))
if rel_p.is_absolute() or ".." in rel_p.parts:
return None
base = (entry / ARTEFACTS_SUBDIR).resolve()
p = (base / rel_p).resolve()
try:
p.relative_to(base)
except ValueError:
return None
if p.is_file() and p.suffix.lower() in ARTEFACT_EXTS:
return p
return None