Add files page and router, update settings, sessions, and notes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
224
backend/app/routers/files.py
Normal file
224
backend/app/routers/files.py
Normal 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)),
|
||||
}
|
||||
@@ -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,))
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
||||
<Route path="/sessions/:id/notes" element={<Notes />} />
|
||||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/diagnostics" element={<Diagnostics />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -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)}`;
|
||||
},
|
||||
};
|
||||
|
||||
336
frontend/src/pages/Files.tsx
Normal file
336
frontend/src/pages/Files.tsx
Normal 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">← 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}>
|
||||
↑ 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 · <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>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,18 @@ export default function Notes() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 whitespace-pre-wrap leading-relaxed">
|
||||
{tab === "dm" ? notes.dm_notes : notes.player_recap}
|
||||
<div className="card p-6">
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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' && (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function Sessions() {
|
||||
</div>
|
||||
|
||||
<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="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
|
||||
</div>
|
||||
|
||||
@@ -21,58 +21,60 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
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">← Back</Link>
|
||||
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
|
||||
|
||||
<div className="card p-6 space-y-4 mb-6">
|
||||
<h2 className="eyebrow">Transcription</h2>
|
||||
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
|
||||
<option value="tiny">Tiny</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large-v3">Large v3</option>
|
||||
</select>
|
||||
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<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 className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="card p-6 space-y-4">
|
||||
<h2 className="eyebrow">Transcription</h2>
|
||||
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
|
||||
<option value="tiny">Tiny</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large-v3">Large v3</option>
|
||||
</select>
|
||||
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
|
||||
</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">
|
||||
<h2 className="eyebrow">Campaign context</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..." />
|
||||
<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" />
|
||||
</div>
|
||||
<div className="card p-6 space-y-4">
|
||||
<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>
|
||||
{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">
|
||||
<h2 className="eyebrow">Player recap style</h2>
|
||||
<select className="input w-full" value={settings.player_recap_style} onChange={(e) => set("player_recap_style", e.target.value)}>
|
||||
<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 className="card p-6 space-y-3">
|
||||
<h2 className="eyebrow">Campaign context</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..." />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="card p-6 space-y-3">
|
||||
<h2 className="eyebrow">Player recap style</h2>
|
||||
<select className="input w-full" value={settings.player_recap_style} onChange={(e) => set("player_recap_style", e.target.value)}>
|
||||
<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>
|
||||
|
||||
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
|
||||
|
||||
Reference in New Issue
Block a user