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