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>
110 lines
3.9 KiB
Python
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
|