diff --git a/backend/app/database.py b/backend/app/database.py index d2c47ee..8ed2d82 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -63,9 +63,15 @@ def init_db(): session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, dm_notes TEXT, player_recap TEXT, + player_recap_prompt TEXT, generated_at REAL ) """) + # migrate older databases that lack the prompt column + try: + conn.execute("ALTER TABLE notes ADD COLUMN player_recap_prompt TEXT") + except Exception: + pass conn.execute(""" CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, diff --git a/backend/app/main.py b/backend/app/main.py index fa68697..3558f2a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from .routers import notes as notes_router from .routers import jobs as jobs_router from .routers import models as models_router from .routers import diagnostics as diagnostics_router +from .routers import files as files_router setup_logging() log = get_logger(__name__) @@ -52,6 +53,7 @@ app.include_router(notes_router.router) app.include_router(jobs_router.router) app.include_router(models_router.router) app.include_router(diagnostics_router.router) +app.include_router(files_router.router) @app.get("/api/sessions/{session_id}/audio") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py new file mode 100644 index 0000000..f7162f1 --- /dev/null +++ b/backend/app/routers/files.py @@ -0,0 +1,224 @@ +import shutil +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, Response + +from .. import database as db, config + +router = APIRouter(prefix="/api/files", tags=["files"]) + +DATA = config.DATA_DIR.resolve() + + +def _safe_path(path_str: str) -> Path: + resolved = (DATA / path_str).resolve() + if not str(resolved).startswith(str(DATA)): + raise HTTPException(400, "Path traversal denied") + return resolved + + +def _auto_dest(source: Path) -> Path: + parent = source.parent + stem = source.stem + suffix = source.suffix + candidate = parent / f"{stem}_copy{suffix}" + n = 2 + while candidate.exists(): + candidate = parent / f"{stem}_copy_{n}{suffix}" + n += 1 + return candidate + + +def _auto_name(parent: Path, name: str) -> Path: + p = parent / name + if not p.exists(): + return p + stem = Path(name).stem + suffix = Path(name).suffix + n = 2 + while (parent / f"{stem}_copy_{n}{suffix}").exists(): + n += 1 + return parent / f"{stem}_copy_{n}{suffix}" + + +def _enrich(entries, conn): + for e in entries: + if e["type"] == "file": + sid = e["name"].split("_")[0] + e["session_id"] = sid + row = conn.execute("SELECT name, status FROM sessions WHERE id = ?", (sid,)).fetchone() + e["session_name"] = row["name"] if row else "(orphan)" + e["session_status"] = row["status"] if row else "unknown" + + +@router.get("/browse") +def browse(path: str = ""): + clean = path.strip("/") + entries = [] + conn = db.get_conn() + + # Root level — show the three directories + if not clean: + for name in sorted(["audio", "transcriptions", "notes"]): + entries.append({"name": name, "type": "dir", "path": name}) + return {"entries": entries, "current_path": "", "parent_path": None} + + # Virtual "notes" directory — list from DB + if clean == "notes": + rows = conn.execute( + "SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC" + ).fetchall() + for r in rows: + for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])): + if not text: + continue + entries.append({ + "name": f"{r['session_id']}_{kind}.txt", + "type": "file", + "path": f"notes/{r['session_id']}_{kind}.txt", + "notes_kind": kind, + "size": len(text), + "modified_at": int(r["generated_at"] or 0), + "preview": text[:120], + }) + _enrich(entries, conn) + return {"entries": entries, "current_path": "notes", "parent_path": ""} + + # Real directory on disk + resolved = DATA / clean + if not resolved.exists() or not resolved.is_dir(): + raise HTTPException(404, "Directory not found") + + for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + if f.name.startswith("tmp_"): + continue + rel = str(f.relative_to(DATA)) + entry = {"name": f.name, "type": "dir" if f.is_dir() else "file", "path": rel} + if not f.is_dir(): + entry["size"] = f.stat().st_size + entry["modified_at"] = int(f.stat().st_mtime) + entries.append(entry) + + _enrich([e for e in entries if e["type"] == "file"], conn) + + parts = clean.split("/") + parent = "/".join(parts[:-1]) if len(parts) > 1 else "" + return {"entries": entries, "current_path": clean, "parent_path": parent or None} + + +_MIME_MAP: dict[str, str] = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".m4a": "audio/mp4", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".json": "application/json", + ".txt": "text/plain", +} + + +@router.get("/view") +def view_file(path: str): + # Virtual notes file — serve from DB + if path.startswith("notes/"): + parts = Path(path).stem.split("_") + session_id = parts[0] + kind = parts[1] if len(parts) > 1 else None + conn = db.get_conn() + row = conn.execute("SELECT dm_notes, player_recap FROM notes WHERE session_id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Notes not found") + text = row[kind] if kind in ("dm_notes", "player_recap") else (row["player_recap"] or row["dm_notes"] or "") + return Response(content=text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": "inline"}) + + # Real file on disk + file_path = _safe_path(path) + if not file_path.exists(): + raise HTTPException(404, "File not found") + + mime = _MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream") + return FileResponse(file_path, media_type=mime, headers={"Content-Disposition": "inline"}) + + +@router.get("/download") +def download_file(path: str): + file_path = _safe_path(path) + if not file_path.exists(): + raise HTTPException(404, "File not found") + return FileResponse(file_path, filename=file_path.name) + + +@router.post("/upload") +async def upload_file(request: Request): + form = await request.form() + file_field = form.get("file") + if not file_field or not hasattr(file_field, "filename") or not file_field.filename: + raise HTTPException(400, "No file provided") + dest_dir_str = form.get("dir", "audio") + dest_dir = _safe_path(dest_dir_str) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = _auto_name(dest_dir, file_field.filename) + with open(dest, "wb") as f: + content = await file_field.read() + f.write(content) + return {"path": str(dest.relative_to(DATA))} + + +@router.delete("") +def delete_files(body: dict): + paths = body.get("paths", []) + deleted = [] + errors = [] + for p in paths: + try: + fp = _safe_path(p) + if fp.exists(): + fp.unlink() + deleted.append(p) + elif p.startswith("notes/"): + parts = Path(p).stem.split("_") + session_id = parts[0] + conn = db.get_conn() + kind = parts[1] if len(parts) > 1 else None + if kind == "dm_notes": + conn.execute("UPDATE notes SET dm_notes = NULL WHERE session_id = ?", (session_id,)) + elif kind == "player_recap": + conn.execute("UPDATE notes SET player_recap = NULL WHERE session_id = ?", (session_id,)) + else: + conn.execute("DELETE FROM notes WHERE session_id = ?", (session_id,)) + conn.commit() + deleted.append(p) + else: + errors.append({"path": p, "error": "not found"}) + except Exception as e: + errors.append({"path": p, "error": str(e)}) + return {"deleted": deleted, "errors": errors} + + +@router.post("/copy") +def copy_file(body: dict): + source_str = body.get("source") + dest_dir_str = body.get("dest_dir") + if not source_str: + raise HTTPException(400, "source is required") + source = _safe_path(source_str) + if not source.exists(): + raise HTTPException(404, "Source file not found") + + if dest_dir_str: + dest_dir = _safe_path(dest_dir_str) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = _auto_name(dest_dir, source.name) + else: + dest = _auto_dest(source) + + if dest.exists(): + raise HTTPException(409, f"Destination already exists: {dest.name}") + + shutil.copy2(source, dest) + return { + "source": str(source.relative_to(DATA)), + "dest": str(dest.relative_to(DATA)), + } diff --git a/backend/app/routers/notes.py b/backend/app/routers/notes.py index f8103f7..f424553 100644 --- a/backend/app/routers/notes.py +++ b/backend/app/routers/notes.py @@ -1,16 +1,17 @@ from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from .. import database as db, jobs from ..pipeline.turns import load_turns -from ..pipeline.summarize import summarize_session +from ..pipeline.summarize import summarize_session, PLAYER_FINAL_PROMPTS router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"]) @router.post("/generate") -def generate_notes(session_id: str): +async def generate_notes(session_id: str, request: Request): + body = await request.json() if request.headers.get("content-type") else {} session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() if not session: raise HTTPException(404, "Session not found") @@ -22,21 +23,31 @@ def generate_notes(session_id: str): db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,)) if r["display_name"]} + # Per-request style overrides the global setting + style = body.get("player_recap_style") or settings.get("player_recap_style") or "story" + custom = body.get("player_recap_custom_prompt") or settings.get("player_recap_custom_prompt") or "" + + # Resolve the prompt template (same logic as summarize_session) + if style == "custom": + player_template = custom if custom else PLAYER_FINAL_PROMPTS["story"] + else: + player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"]) + job_id = db.create_job(session_id, "summarize") def run(progress_cb): turns = load_turns(Path(session["transcript_path"]), speaker_map) dm_notes, player_recap = summarize_session( turns, settings, progress_cb=progress_cb, - player_recap_style=settings.get("player_recap_style"), - player_recap_custom_prompt=settings.get("player_recap_custom_prompt"), + player_recap_style=style, + player_recap_custom_prompt=custom, ) with db.tx() as conn: conn.execute( - "INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) " + "INSERT INTO notes (session_id, dm_notes, player_recap, player_recap_prompt, generated_at) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, " - "player_recap=excluded.player_recap, generated_at=excluded.generated_at", - (session_id, dm_notes, player_recap, db.now()), + "player_recap=excluded.player_recap, player_recap_prompt=excluded.player_recap_prompt, generated_at=excluded.generated_at", + (session_id, dm_notes, player_recap, player_template, db.now()), ) conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4712279..a6ada8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Sessions from './pages/Sessions'; import SessionDetail from './pages/SessionDetail'; import Speakers from './pages/Speakers'; import Notes from './pages/Notes'; +import Files from './pages/Files'; import Settings from './pages/Settings'; import Diagnostics from './pages/Diagnostics'; import { api } from './api'; @@ -45,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6f3c978..d7ea2f8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -133,8 +133,12 @@ export const api = { if (!res.ok) throw new Error('Notes not generated yet'); return res.json(); }, - generateNotes: async (sessionId: string): Promise<{ job_id: string }> => { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes/generate`, { method: 'POST' }); + generateNotes: async (sessionId: string, options?: { player_recap_style?: string; player_recap_custom_prompt?: string }): Promise<{ job_id: string }> => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes/generate`, { + method: 'POST', + headers: options ? { 'Content-Type': 'application/json' } : undefined, + body: options ? JSON.stringify(options) : undefined, + }); if (!res.ok) throw new Error('Failed to start note generation'); return res.json(); }, @@ -189,4 +193,36 @@ export const api = { } return lastResult; }, + + // Files + browseFiles: async (path?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => { + const qs = path ? `?path=${encodeURIComponent(path)}` : ''; + const res = await fetch(`${BASE_URL}/files/browse${qs}`); + if (!res.ok) throw new Error('Failed to browse files'); + return res.json(); + }, + deleteFiles: async (paths: string[]): Promise<{ deleted: string[]; errors: any[] }> => { + const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) }); + if (!res.ok) throw new Error('Failed to delete files'); + return res.json(); + }, + uploadFile: async (file: File, dir?: string): Promise<{ path: string }> => { + const form = new FormData(); + form.append('file', file); + if (dir) form.append('dir', dir); + const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form }); + if (!res.ok) throw new Error('Failed to upload file'); + return res.json(); + }, + copyFile: async (source: string, dest_dir?: string): Promise<{ source: string; dest: string }> => { + const res = await fetch(`${BASE_URL}/files/copy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, dest_dir }) }); + if (!res.ok) throw new Error('Failed to copy file'); + return res.json(); + }, + downloadFileUrl: (path: string): string => { + return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`; + }, + viewFileUrl: (path: string): string => { + return `${BASE_URL}/files/view?path=${encodeURIComponent(path)}`; + }, }; diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx new file mode 100644 index 0000000..e5ad6b4 --- /dev/null +++ b/frontend/src/pages/Files.tsx @@ -0,0 +1,336 @@ +import { useEffect, useState, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDate(ts: number): string { + if (!ts) return "-"; + return new Date(ts * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function Breadcrumb({ path, onNavigate }: { path: string; onNavigate: (p: string) => void }) { + const parts = path ? path.split("/") : []; + return ( + + ); +} + +const AUDIO_EXTS = new Set([".wav", ".mp3", ".mp4", ".m4a", ".ogg", ".flac"]); + +function isAudio(name: string) { + return AUDIO_EXTS.has(name.slice(name.lastIndexOf(".")).toLowerCase()); +} + +function isText(path: string) { + return path.startsWith("notes/") || path.endsWith(".json") || path.endsWith(".txt"); +} + +export default function Files() { + const [entries, setEntries] = useState([]); + const [currentPath, setCurrentPath] = useState(""); + const [parentPath, setParentPath] = useState(null); + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null); + const [loading, setLoading] = useState(true); + const [toast, setToast] = useState(null); + const [uploading, setUploading] = useState(false); + const [expandedPath, setExpandedPath] = useState(null); + const [expandedContent, setExpandedContent] = useState(null); + const [expanding, setExpanding] = useState(false); + + const load = useCallback(async (path: string) => { + setLoading(true); + setExpandedPath(null); + setExpandedContent(null); + try { + const res = await api.browseFiles(path || undefined); + setEntries(res.entries); + setCurrentPath(res.current_path); + setParentPath(res.parent_path); + } catch { setEntries([]); } + setLoading(false); + }, []); + + useEffect(() => { load(currentPath); }, []); + + const navigate = (path: string) => { + setSelectedPaths(new Set()); + load(path); + }; + + const showToast = (msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 2000); + }; + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if ((e.ctrlKey || e.metaKey) && e.key === "c") { + e.preventDefault(); + if (selectedPaths.size > 0) { + const first = entries.find(f => selectedPaths.has(f.path)); + if (first) { setCopiedFile({ path: first.path, name: first.name }); showToast(`Copied: ${first.name}`); } + } + } + if ((e.ctrlKey || e.metaKey) && e.key === "v") { + e.preventDefault(); + if (copiedFile) handlePaste(); + } + if (e.key === "Delete" && selectedPaths.size > 0) handleDelete(); + if ((e.ctrlKey || e.metaKey) && e.key === "a") { + e.preventDefault(); + setSelectedPaths(new Set(entries.map(e => e.path))); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [selectedPaths, copiedFile, entries]); + + const toggleSelect = (path: string) => { + setSelectedPaths(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const handleDelete = async () => { + const paths = Array.from(selectedPaths); + if (paths.length === 0) return; + if (!confirm(`Delete ${paths.length} item(s)?`)) return; + try { + const res = await api.deleteFiles(paths); + setSelectedPaths(new Set()); + setExpandedPath(null); + setExpandedContent(null); + showToast(`Deleted ${res.deleted.length} item(s)`); + load(currentPath); + } catch (e: any) { + showToast(e.message || "Delete failed"); + } + }; + + const handlePaste = async () => { + if (!copiedFile) return; + try { + const res = await api.copyFile(copiedFile.path, currentPath || undefined); + setCopiedFile(null); + showToast(`Pasted as: ${res.dest.split("/").pop()}`); + load(currentPath); + } catch (e: any) { + showToast(e.message || "Paste failed"); + } + }; + + const handleUpload = async () => { + const input = document.createElement("input"); + input.type = "file"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + setUploading(true); + try { + await api.uploadFile(file, currentPath || undefined); + showToast("Uploaded"); + load(currentPath); + } catch (e: any) { + showToast(e.message || "Upload failed"); + } + setUploading(false); + }; + input.click(); + }; + + const handleExpand = async (entry: any) => { + if (expandedPath === entry.path) { + setExpandedPath(null); + setExpandedContent(null); + return; + } + setExpandedPath(entry.path); + if (isAudio(entry.name)) { + setExpandedContent(null); + } else if (isText(entry.path)) { + setExpanding(true); + try { + const res = await fetch(api.viewFileUrl(entry.path)); + const text = await res.text(); + setExpandedContent(text); + } catch { + setExpandedContent("(failed to load)"); + } + setExpanding(false); + } + }; + + const selectedCount = selectedPaths.size; + const dirs = entries.filter(e => e.type === "dir"); + const files = entries.filter(e => e.type === "file"); + + return ( +
+ {toast && ( +
+ {toast} +
+ )} + ← Back +

File Browser

+ + + + {/* Toolbar */} +
+
+
+ +
+
+ {copiedFile && ( + + Clipboard: {copiedFile.name} + + )} + + + + + +
+
+ {selectedCount > 0 && ( +
+ {selectedCount} selected · +
+ )} +
+ + {/* Entry list */} +
+ {loading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
This folder is empty.
+ ) : ( + + + + + + + + + + + + {[...dirs, ...files].map((e) => { + const checked = selectedPaths.has(e.path); + const isExpanded = expandedPath === e.path; + return ( + + { + if (e.type === "dir") navigate(e.path); + else handleExpand(e); + }} + > + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} +
+ { + if (selectedPaths.size === entries.length) setSelectedPaths(new Set()); + else setSelectedPaths(new Set(entries.map(e => e.path))); + }} /> + NameTypeSessionSizeModified
{ ev.stopPropagation(); toggleSelect(e.path); }}> + + + {e.type === "dir" ? "\uD83D\uDCC1" : isExpanded ? "\u25BC" : "\uD83D\uDCC4"} + {e.type === "dir" ? ( + + ) : ( + + )} + + + {e.type === "dir" ? "Folder" : e.notes_kind === "dm_notes" ? "DM Notes" : e.notes_kind === "player_recap" ? "Player Recap" : (e.path?.startsWith("notes") ? "Notes" : e.path?.startsWith("transcriptions") ? "Transcript" : "Audio")} + + + {e.session_name && e.session_name !== "(orphan)" ? ( + ev.stopPropagation()}>{e.session_name} + ) : e.session_name ? ( + {e.session_name} + ) : ( + - + )} + {e.size != null ? formatSize(e.size) : "-"}{e.modified_at != null ? formatDate(e.modified_at) : "-"}
+
+ {isAudio(e.name) ? ( + + ) : expanding ? ( +
Loading...
+ ) : ( +
+                                {expandedContent}
+                              
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Notes.tsx b/frontend/src/pages/Notes.tsx index c55ca33..fedbb07 100644 --- a/frontend/src/pages/Notes.tsx +++ b/frontend/src/pages/Notes.tsx @@ -27,8 +27,18 @@ export default function Notes() { -
- {tab === "dm" ? notes.dm_notes : notes.player_recap} +
+ {tab === "player" && notes.player_recap_prompt && ( +
+ Prompt used +
+              {notes.player_recap_prompt}
+            
+
+ )} +
+ {tab === "dm" ? notes.dm_notes : notes.player_recap} +
); diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 0e516f7..456ad1f 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -210,9 +210,17 @@ export default function SessionDetail() { load(); }; + const [showStyleModal, setShowStyleModal] = useState(false); + const [styleChoice, setStyleChoice] = useState<'story' | 'diary' | 'bullets' | 'custom'>('story'); + const [customPrompt, setCustomPrompt] = useState(''); + const generateNotes = async () => { if (!id) return; - await api.generateNotes(id); + const opts = styleChoice === 'custom' + ? { player_recap_style: styleChoice, player_recap_custom_prompt: customPrompt } + : { player_recap_style: styleChoice }; + await api.generateNotes(id, opts); + setShowStyleModal(false); load(); }; @@ -248,10 +256,43 @@ export default function SessionDetail() { )} {session.status === 'complete' && (
- + View notes
)} + + {showStyleModal && ( +
+
+

Player Recap Style

+

+ Choose how you'd like the player-facing recap to be written. +

+ + {styleChoice === 'custom' && ( +