Files
ai-agent/backend/projects/artefacts.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

112 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
from services import catalog 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