Add files page and router, update settings, sessions, and notes

This commit is contained in:
KansaiGaijin
2026-07-10 18:28:55 +12:00
parent babc0f58ef
commit db19cfd05e
11 changed files with 731 additions and 60 deletions

View File

@@ -63,9 +63,15 @@ def init_db():
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
dm_notes TEXT, dm_notes TEXT,
player_recap TEXT, player_recap TEXT,
player_recap_prompt TEXT,
generated_at REAL 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS jobs ( CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@@ -13,6 +13,7 @@ from .routers import notes as notes_router
from .routers import jobs as jobs_router from .routers import jobs as jobs_router
from .routers import models as models_router from .routers import models as models_router
from .routers import diagnostics as diagnostics_router from .routers import diagnostics as diagnostics_router
from .routers import files as files_router
setup_logging() setup_logging()
log = get_logger(__name__) log = get_logger(__name__)
@@ -52,6 +53,7 @@ app.include_router(notes_router.router)
app.include_router(jobs_router.router) app.include_router(jobs_router.router)
app.include_router(models_router.router) app.include_router(models_router.router)
app.include_router(diagnostics_router.router) app.include_router(diagnostics_router.router)
app.include_router(files_router.router)
@app.get("/api/sessions/{session_id}/audio") @app.get("/api/sessions/{session_id}/audio")

View File

@@ -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)),
}

View File

@@ -1,16 +1,17 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from .. import database as db, jobs from .. import database as db, jobs
from ..pipeline.turns import load_turns 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 = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"])
@router.post("/generate") @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() session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not session: if not session:
raise HTTPException(404, "Session not found") 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,)) db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
if r["display_name"]} 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") job_id = db.create_job(session_id, "summarize")
def run(progress_cb): def run(progress_cb):
turns = load_turns(Path(session["transcript_path"]), speaker_map) turns = load_turns(Path(session["transcript_path"]), speaker_map)
dm_notes, player_recap = summarize_session( dm_notes, player_recap = summarize_session(
turns, settings, progress_cb=progress_cb, turns, settings, progress_cb=progress_cb,
player_recap_style=settings.get("player_recap_style"), player_recap_style=style,
player_recap_custom_prompt=settings.get("player_recap_custom_prompt"), player_recap_custom_prompt=custom,
) )
with db.tx() as conn: with db.tx() as conn:
conn.execute( 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, " "ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, "
"player_recap=excluded.player_recap, generated_at=excluded.generated_at", "player_recap=excluded.player_recap, player_recap_prompt=excluded.player_recap_prompt, generated_at=excluded.generated_at",
(session_id, dm_notes, player_recap, db.now()), (session_id, dm_notes, player_recap, player_template, db.now()),
) )
conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,)) conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,))

View File

@@ -5,6 +5,7 @@ import Sessions from './pages/Sessions';
import SessionDetail from './pages/SessionDetail'; import SessionDetail from './pages/SessionDetail';
import Speakers from './pages/Speakers'; import Speakers from './pages/Speakers';
import Notes from './pages/Notes'; import Notes from './pages/Notes';
import Files from './pages/Files';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Diagnostics from './pages/Diagnostics'; import Diagnostics from './pages/Diagnostics';
import { api } from './api'; import { api } from './api';
@@ -45,6 +46,7 @@ function App() {
<Route path="/sessions/:id" element={<SessionDetail />} /> <Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/sessions/:id/speakers" element={<Speakers />} /> <Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} /> <Route path="/sessions/:id/notes" element={<Notes />} />
<Route path="/files" element={<Files />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} /> <Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />

View File

@@ -133,8 +133,12 @@ export const api = {
if (!res.ok) throw new Error('Notes not generated yet'); if (!res.ok) throw new Error('Notes not generated yet');
return res.json(); return res.json();
}, },
generateNotes: async (sessionId: string): Promise<{ job_id: string }> => { 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' }); 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'); if (!res.ok) throw new Error('Failed to start note generation');
return res.json(); return res.json();
}, },
@@ -189,4 +193,36 @@ export const api = {
} }
return lastResult; 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)}`;
},
}; };

View File

@@ -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 (
<nav className="flex items-center gap-1 text-sm font-mono mb-6 text-ink/60">
<button className="hover:text-brass" onClick={() => onNavigate("")}>Files</button>
{parts.map((part, i) => {
const full = parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
return (
<span key={full} className="flex items-center gap-1">
<span className="text-ink/30">/</span>
{isLast ? (
<span className="text-ink/90 font-semibold">{part}</span>
) : (
<button className="hover:text-brass" onClick={() => onNavigate(full)}>{part}</button>
)}
</span>
);
})}
</nav>
);
}
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<any[]>([]);
const [currentPath, setCurrentPath] = useState("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null);
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [expandedPath, setExpandedPath] = useState<string | null>(null);
const [expandedContent, setExpandedContent] = useState<string | null>(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 (
<div className="max-w-5xl mx-auto py-16 px-4">
{toast && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-emerald-700 text-white px-5 py-3 rounded-xl shadow-2xl text-sm font-medium">
{toast}
</div>
)}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1>
<Breadcrumb path={currentPath} onNavigate={navigate} />
{/* Toolbar */}
<div className="card p-4 mb-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex gap-2">
<button className="btn-secondary text-sm" onClick={() => navigate(parentPath || "")} disabled={!parentPath}>
&uarr; Up
</button>
</div>
<div className="flex gap-2 items-center">
{copiedFile && (
<span className="text-xs text-brass font-mono truncate max-w-[200px]" title={copiedFile.path}>
Clipboard: {copiedFile.name}
</span>
)}
<button className="btn-secondary text-sm" onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
<button className="btn-secondary text-sm" onClick={() => {
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}`); }
}
}} disabled={selectedCount === 0}>Copy</button>
<button className="btn-secondary text-sm" onClick={handlePaste} disabled={!copiedFile}>Paste</button>
<button className="btn-secondary text-sm" onClick={handleDelete} disabled={selectedCount === 0}>Delete</button>
<button className="btn-secondary text-sm" onClick={() => {
const first = entries.find(f => selectedPaths.has(f.path));
if (first) window.open(api.downloadFileUrl(first.path), "_blank");
}} disabled={selectedCount !== 1}>Download</button>
</div>
</div>
{selectedCount > 0 && (
<div className="mt-2 text-xs text-ink/40">
{selectedCount} selected &middot; <button className="underline hover:text-brass" onClick={() => setSelectedPaths(new Set())}>clear</button>
</div>
)}
</div>
{/* Entry list */}
<div className="card overflow-hidden">
{loading ? (
<div className="p-6 text-center text-ink/40">Loading...</div>
) : entries.length === 0 ? (
<div className="p-6 text-center text-ink/40">This folder is empty.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-ink/50 text-xs uppercase tracking-wider">
<th className="p-3 w-10">
<input type="checkbox" className="accent-brass" checked={selectedPaths.size === entries.length} onChange={() => {
if (selectedPaths.size === entries.length) setSelectedPaths(new Set());
else setSelectedPaths(new Set(entries.map(e => e.path)));
}} />
</th>
<th className="p-3 text-left">Name</th>
<th className="p-3 text-left w-24">Type</th>
<th className="p-3 text-left w-40">Session</th>
<th className="p-3 text-right w-20">Size</th>
<th className="p-3 text-right w-32">Modified</th>
</tr>
</thead>
{[...dirs, ...files].map((e) => {
const checked = selectedPaths.has(e.path);
const isExpanded = expandedPath === e.path;
return (
<tbody key={e.path}>
<tr
className={`border-b border-white/5 hover:bg-white/5 cursor-pointer ${checked ? "bg-brass/5" : ""} ${isExpanded ? "bg-white/[0.03]" : ""}`}
onClick={() => {
if (e.type === "dir") navigate(e.path);
else handleExpand(e);
}}
>
<td className="p-3 w-10" onClick={(ev) => { ev.stopPropagation(); toggleSelect(e.path); }}>
<input type="checkbox" className="accent-brass" checked={checked} readOnly />
</td>
<td className="p-3 font-mono text-xs truncate max-w-[300px]" title={e.name}>
<span className="mr-2">{e.type === "dir" ? "\uD83D\uDCC1" : isExpanded ? "\u25BC" : "\uD83D\uDCC4"}</span>
{e.type === "dir" ? (
<button className="hover:text-brass text-left" onClick={() => navigate(e.path)}>{e.name}</button>
) : (
<button className="hover:text-brass text-left">{e.name}</button>
)}
</td>
<td className="p-3">
<span className="px-2 py-0.5 rounded text-xs font-medium bg-white/5 text-ink/60">
{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")}
</span>
</td>
<td className="p-3 truncate max-w-[160px]" title={e.session_name}>
{e.session_name && e.session_name !== "(orphan)" ? (
<Link to={`/sessions/${e.session_id}`} className="hover:text-brass" onClick={(ev) => ev.stopPropagation()}>{e.session_name}</Link>
) : e.session_name ? (
<span className="text-ink/40">{e.session_name}</span>
) : (
<span className="text-ink/20">-</span>
)}
</td>
<td className="p-3 text-right text-ink/50 font-mono text-xs">{e.size != null ? formatSize(e.size) : "-"}</td>
<td className="p-3 text-right text-ink/50 text-xs">{e.modified_at != null ? formatDate(e.modified_at) : "-"}</td>
</tr>
{isExpanded && (
<tr className="border-b border-white/5">
<td colSpan={6} className="p-0">
<div className="bg-black/20 px-4 py-4">
{isAudio(e.name) ? (
<audio controls className="w-full max-w-xl" src={api.viewFileUrl(e.path)} autoPlay>
Your browser does not support audio playback.
</audio>
) : expanding ? (
<div className="text-ink/40 text-xs">Loading...</div>
) : (
<pre className="text-xs text-ink/80 whitespace-pre-wrap font-mono leading-relaxed max-h-[60vh] overflow-y-auto">
{expandedContent}
</pre>
)}
</div>
</td>
</tr>
)}
</tbody>
);
})}
</table>
)}
</div>
</div>
);
}

View File

@@ -27,8 +27,18 @@ export default function Notes() {
</button> </button>
</div> </div>
<div className="card p-6 whitespace-pre-wrap leading-relaxed"> <div className="card p-6">
{tab === "dm" ? notes.dm_notes : notes.player_recap} {tab === "player" && notes.player_recap_prompt && (
<div className="mb-6 pb-4 border-b border-white/10">
<span className="eyebrow">Prompt used</span>
<pre className="bg-black/30 border border-white/10 rounded p-3 text-xs text-ink/70 mt-2 whitespace-pre-wrap font-mono leading-relaxed">
{notes.player_recap_prompt}
</pre>
</div>
)}
<div className="whitespace-pre-wrap leading-relaxed">
{tab === "dm" ? notes.dm_notes : notes.player_recap}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -210,9 +210,17 @@ export default function SessionDetail() {
load(); load();
}; };
const [showStyleModal, setShowStyleModal] = useState(false);
const [styleChoice, setStyleChoice] = useState<'story' | 'diary' | 'bullets' | 'custom'>('story');
const [customPrompt, setCustomPrompt] = useState('');
const generateNotes = async () => { const generateNotes = async () => {
if (!id) return; 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(); load();
}; };
@@ -248,10 +256,43 @@ export default function SessionDetail() {
)} )}
{session.status === 'complete' && ( {session.status === 'complete' && (
<div className="mt-4 flex gap-3"> <div className="mt-4 flex gap-3">
<button className="btn-secondary" onClick={generateNotes}>Regenerate notes</button> <button className="btn-secondary" onClick={() => setShowStyleModal(true)}>Regenerate notes</button>
<Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link> <Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link>
</div> </div>
)} )}
{showStyleModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
<h4 className="text-lg font-bold mb-4">Player Recap Style</h4>
<p className="text-sm text-ink/60 mb-4 leading-relaxed">
Choose how you'd like the player-facing recap to be written.
</p>
<select
className="input mb-4"
value={styleChoice}
onChange={(e) => setStyleChoice(e.target.value as any)}
>
<option value="story">Story Recap</option>
<option value="diary">Dear Diary</option>
<option value="bullets">Bullet Points</option>
<option value="custom">Custom Prompt</option>
</select>
{styleChoice === 'custom' && (
<textarea
className="input min-h-[100px] mb-4"
placeholder="Enter your custom prompt for the player recap..."
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
/>
)}
<div className="flex justify-end gap-3">
<button className="btn-secondary text-sm" onClick={() => setShowStyleModal(false)}>Cancel</button>
<button className="btn-primary text-sm" onClick={generateNotes}>Generate</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -87,6 +87,7 @@ export default function Sessions() {
</div> </div>
<div className="mt-10 flex gap-4"> <div className="mt-10 flex gap-4">
<Link to="/files" className="text-sm text-ink/40 hover:text-brass">Files</Link>
<Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link> <Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link>
<Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link> <Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
</div> </div>

View File

@@ -21,58 +21,60 @@ export default function Settings() {
}; };
return ( return (
<div className="max-w-xl mx-auto py-16 px-4"> <div className="max-w-4xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link> <Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1> <h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
<div className="card p-6 space-y-4 mb-6"> <div className="grid grid-cols-2 gap-6 mb-6">
<h2 className="eyebrow">Transcription</h2> <div className="card p-6 space-y-4">
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}> <h2 className="eyebrow">Transcription</h2>
<option value="tiny">Tiny</option> <select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
<option value="small">Small</option> <option value="tiny">Tiny</option>
<option value="medium">Medium</option> <option value="small">Small</option>
<option value="large-v3">Large v3</option> <option value="medium">Medium</option>
</select> <option value="large-v3">Large v3</option>
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} /> </select>
</div> <input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
<div className="card p-6 space-y-4 mb-6">
<h2 className="eyebrow">Summarization</h2>
<div className="flex gap-3">
<button className={settings.llm_mode === "ollama" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "ollama")}>Local (Ollama)</button>
<button className={settings.llm_mode === "api" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "api")}>Hosted API</button>
</div> </div>
{settings.llm_mode === "ollama" ? (
<>
<input className="input w-full" value={settings.ollama_host} onChange={(e) => set("ollama_host", e.target.value)} />
<input className="input w-full" value={settings.ollama_model} onChange={(e) => set("ollama_model", e.target.value)} />
</>
) : (
<>
<input className="input w-full" value={settings.api_base_url} onChange={(e) => set("api_base_url", e.target.value)} />
<input className="input w-full" type="password" value={settings.api_key} onChange={(e) => set("api_key", e.target.value)} />
<input className="input w-full" value={settings.api_model} onChange={(e) => set("api_model", e.target.value)} />
</>
)}
</div>
<div className="card p-6 space-y-3 mb-6"> <div className="card p-6 space-y-4">
<h2 className="eyebrow">Campaign context</h2> <h2 className="eyebrow">Summarization</h2>
<textarea className="input w-full h-32" value={settings.world_context} onChange={(e) => set("world_context", e.target.value)} placeholder="Paste world context directly..." /> <div className="flex gap-3">
<input className="input w-full" value={settings.world_context_path} onChange={(e) => set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" /> <button className={settings.llm_mode === "ollama" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "ollama")}>Local (Ollama)</button>
</div> <button className={settings.llm_mode === "api" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "api")}>Hosted API</button>
</div>
{settings.llm_mode === "ollama" ? (
<>
<input className="input w-full" value={settings.ollama_host} onChange={(e) => set("ollama_host", e.target.value)} />
<input className="input w-full" value={settings.ollama_model} onChange={(e) => set("ollama_model", e.target.value)} />
</>
) : (
<>
<input className="input w-full" value={settings.api_base_url} onChange={(e) => set("api_base_url", e.target.value)} />
<input className="input w-full" type="password" value={settings.api_key} onChange={(e) => set("api_key", e.target.value)} />
<input className="input w-full" value={settings.api_model} onChange={(e) => set("api_model", e.target.value)} />
</>
)}
</div>
<div className="card p-6 space-y-3 mb-6"> <div className="card p-6 space-y-3">
<h2 className="eyebrow">Player recap style</h2> <h2 className="eyebrow">Campaign context</h2>
<select className="input w-full" value={settings.player_recap_style} onChange={(e) => set("player_recap_style", e.target.value)}> <textarea className="input w-full h-32" value={settings.world_context} onChange={(e) => set("world_context", e.target.value)} placeholder="Paste world context directly..." />
<option value="story">Story Recap (previously on)</option> <input className="input w-full" value={settings.world_context_path} onChange={(e) => set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" />
<option value="diary">Dear Diary</option> </div>
<option value="bullets">Bullet Points</option>
<option value="custom">Custom Prompt</option> <div className="card p-6 space-y-3">
</select> <h2 className="eyebrow">Player recap style</h2>
{settings.player_recap_style === "custom" && ( <select className="input w-full" value={settings.player_recap_style} onChange={(e) => set("player_recap_style", e.target.value)}>
<textarea className="input w-full h-32" value={settings.player_recap_custom_prompt} onChange={(e) => set("player_recap_custom_prompt", e.target.value)} placeholder="Write your own final-combine prompt. Use {'{summaries}'} where the chunk summaries should be inserted." /> <option value="story">Story Recap (previously on)</option>
)} <option value="diary">Dear Diary</option>
<option value="bullets">Bullet Points</option>
<option value="custom">Custom Prompt</option>
</select>
{settings.player_recap_style === "custom" && (
<textarea className="input w-full h-32" value={settings.player_recap_custom_prompt} onChange={(e) => set("player_recap_custom_prompt", e.target.value)} placeholder="Write your own final-combine prompt. Use {'{summaries}'} where the chunk summaries should be inserted." />
)}
</div>
</div> </div>
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button> <button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>