Initial commit: Nat20 Notes — TTRPG session transcription & summarization

This commit is contained in:
KansaiGaijin
2026-07-06 23:45:54 +12:00
commit 0f9e9d4c5c
49 changed files with 2926 additions and 0 deletions

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8020

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nat20 Notes</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body class="bg-deep text-ink font-body">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 8020;
client_max_body_size 500M;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "session-notes-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.6.2",
"vite": "^5.4.6"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

59
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Setup from './pages/Setup';
import Sessions from './pages/Sessions';
import SessionDetail from './pages/SessionDetail';
import Speakers from './pages/Speakers';
import Notes from './pages/Notes';
import Settings from './pages/Settings';
import Diagnostics from './pages/Diagnostics';
import { api } from './api';
function App() {
const [loading, setLoading] = useState(true);
const [isConfigured, setIsConfigured] = useState(false);
const checkSettings = () => {
api.getSettings()
.then(data => {
setIsConfigured(!!(data && data.onboarding_completed));
})
.catch(() => setIsConfigured(false))
.finally(() => setLoading(false));
};
useEffect(() => {
checkSettings();
}, []);
if (loading) {
return <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>;
}
return (
<Routes>
{/* If not configured, force them to the Setup wizard */}
<Route
path="/setup"
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
/>
{/* If configured, serve the main sessions workspace */}
{isConfigured ? (
<>
<Route path="/" element={<Sessions />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} />
<Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} />
</>
) : (
<Route path="*" element={<Navigate to="/setup" />} />
)}
</Routes>
);
}
export default App;

190
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,190 @@
const BASE_URL = '/api';
// Flat settings schema - must mirror backend/app/config.py's DEFAULT_SETTINGS
// exactly. This is the single source of truth; don't reintroduce a nested
// shape here without updating config.py, routers/settings.py, Setup.tsx and
// Settings.tsx together.
export interface AppSettings {
onboarding_completed: boolean;
whisper_model: string;
whisper_compute_type: string;
hf_token: string;
llm_mode: 'ollama' | 'api';
ollama_host: string;
ollama_model: string;
api_base_url: string;
api_key: string;
api_model: string;
chunk_word_target: string;
world_context: string;
world_context_path: string;
}
export type JobStatus = 'queued' | 'running' | 'done' | 'error';
export interface Job {
id: string;
session_id: string;
job_type: 'transcribe' | 'summarize';
status: JobStatus;
progress: string | null;
error: string | null;
error_stage: string | null;
created_at: number;
updated_at: number;
}
export type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
export const jobApi = {
getJob: async (jobId: string): Promise<Job> => {
const res = await fetch(`${BASE_URL}/jobs/${jobId}`);
if (!res.ok) throw new Error('Failed to fetch job');
return res.json();
},
cancelJob: async (jobId: string): Promise<any> => {
const res = await fetch(`${BASE_URL}/jobs/${jobId}/cancel`, { method: 'POST' });
return res.json();
},
deleteJob: async (jobId: string, strategy: DeleteStrategy): Promise<any> => {
const res = await fetch(`${BASE_URL}/jobs/${jobId}?strategy=${strategy}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete job');
return res.json();
},
};
export const api = {
// Settings
getSettings: async (): Promise<AppSettings> => {
const res = await fetch(`${BASE_URL}/settings`);
return res.json();
},
updateSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
const res = await fetch(`${BASE_URL}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return res.json();
},
// kept as an alias since some pages call it by this name
saveSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
return api.updateSettings(settings);
},
// Sessions
listSessions: async (): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions`);
return res.json();
},
createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => {
const CHUNK_THRESHOLD = 90 * 1024 * 1024;
if (file.size > CHUNK_THRESHOLD) {
const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
const formData = new FormData();
formData.append('name', name);
formData.append('upload_path', result.filepath);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session');
return res.json();
}
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session');
return res.json();
},
getSession: async (sessionId: string): Promise<any> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}`);
if (!res.ok) throw new Error('Failed to fetch session');
return res.json();
},
retryTranscription: async (sessionId: string): Promise<{ job_id: string }> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/transcribe`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start transcription');
return res.json();
},
audioUrl: (sessionId: string): string => `${BASE_URL}/sessions/${sessionId}/audio`,
// Speakers
getSpeakers: async (sessionId: string): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`);
return res.json();
},
getTurns: async (sessionId: string): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers/turns`);
return res.json();
},
setSpeakerName: async (sessionId: string, raw_label: string, display_name: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw_label, display_name }),
});
if (!res.ok) throw new Error('Failed to save speaker name');
},
// Notes
getNotes: async (sessionId: string): Promise<any> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes`);
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' });
if (!res.ok) throw new Error('Failed to start note generation');
return res.json();
},
// Diagnostics
getDiagnostics: async (): Promise<{ ok: boolean; checks: any[] }> => {
const res = await fetch(`${BASE_URL}/diagnostics`);
if (!res.ok) throw new Error('Failed to reach backend');
return res.json();
},
// Jobs (re-exported here so pages that only import `api` still work)
deleteJob: jobApi.deleteJob,
cancelJob: jobApi.cancelJob,
getJob: jobApi.getJob,
// Large-file (chunked) upload, used automatically by createSession()
// when the file exceeds 90MB. Sends up to 3 chunks concurrently.
uploadFileInChunks: async (
file: File,
onProgress: (percent: number) => void
): Promise<any> => {
const CHUNK_SIZE = 1024 * 1024 * 15;
const CONCURRENCY = 3;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = crypto.randomUUID();
const uploadOne = async (chunkIndex: number): Promise<any> => {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, file.name);
formData.append('chunk_index', chunkIndex.toString());
formData.append('total_chunks', totalChunks.toString());
formData.append('filename', file.name);
formData.append('upload_id', uploadId);
const res = await fetch(`${BASE_URL}/sessions/upload-chunk`, { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Failed to upload chunk ${chunkIndex}`);
return res.json();
};
let lastResult: any = null;
for (let i = 0; i < totalChunks; i += CONCURRENCY) {
const batch = [];
for (let j = i; j < Math.min(i + CONCURRENCY, totalChunks); j++) {
batch.push(uploadOne(j));
}
const results = await Promise.all(batch);
lastResult = results[results.length - 1];
onProgress(Math.round((Math.min(i + CONCURRENCY, totalChunks) / totalChunks) * 100));
}
return lastResult;
},
};

View File

@@ -0,0 +1,66 @@
import { useMemo, useRef } from "react";
const SPEAKER_COLORS = ["#C9A227", "#5F7A61", "#4E6A87", "#7A5670", "#B4523A", "#8A8F5C"];
function colorFor(label: string, palette: Map<string, string>) {
if (!palette.has(label)) {
palette.set(label, SPEAKER_COLORS[palette.size % SPEAKER_COLORS.length]);
}
return palette.get(label)!;
}
export function SessionReel({
turns,
duration,
onSeek,
currentTime,
}: {
turns: { start: number; end: number; raw_speaker: string }[];
duration: number;
onSeek: (t: number) => void;
currentTime: number;
}) {
const palette = useMemo(() => new Map<string, string>(), []);
const trackRef = useRef<HTMLDivElement>(null);
const valid = duration > 0 && isFinite(duration);
const handleClick = (e: React.MouseEvent) => {
if (!trackRef.current || !valid) return;
const rect = trackRef.current.getBoundingClientRect();
const frac = (e.clientX - rect.left) / rect.width;
onSeek(Math.max(0, Math.min(duration, frac * duration)));
};
const playheadPct = valid ? (currentTime / duration) * 100 : 0;
return (
<div
ref={trackRef}
onClick={handleClick}
className={"relative h-14 rounded-md border overflow-hidden " + (valid ? "bg-deep border-white/10 cursor-pointer" : "bg-panel2 border-white/5")}
role="slider"
aria-label="Session timeline"
>
{valid &&
turns.map((t, i) => {
const left = (t.start / duration) * 100;
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
return (
<div
key={i}
className="absolute top-1.5 bottom-1.5 rounded-sm opacity-70 hover:opacity-100 transition"
style={{ left: `${left}%`, width: `${width}%`, background: colorFor(t.raw_speaker, palette) }}
title={`${t.raw_speaker} @ ${Math.floor(t.start)}s`}
/>
);
})}
<div
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)]"
style={{ left: `${playheadPct}%` }}
/>
</div>
);
}
export { colorFor, SPEAKER_COLORS };

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
const LABELS: Record<string, string> = {
ffmpeg: "ffmpeg",
gpu: "GPU / CUDA",
huggingface_token: "HuggingFace token",
llm_backend: "Summarization backend",
disk_space: "Disk space",
};
export default function Diagnostics() {
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const run = async () => {
setLoading(true);
const r = await api.getDiagnostics().catch((e) => ({ ok: false, checks: [], _error: e.message }));
setResult(r);
setLoading(false);
};
useEffect(() => {
run();
}, []);
return (
<div className="max-w-xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
<button className="btn-secondary mb-6" onClick={run} disabled={loading}>
{loading ? "Checking..." : "Run checks again"}
</button>
{result?._error && (
<div className="card p-4 text-ember">Couldn't reach the backend: {result._error}</div>
)}
<div className="space-y-3">
{result?.checks?.map((c: any) => (
<div key={c.name} className="card p-4 flex gap-3">
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${c.ok ? "bg-moss" : "bg-ember"}`} />
<div>
<div className="font-medium">{LABELS[c.name] || c.name}</div>
<div className="text-sm text-ink/60 whitespace-pre-wrap">{c.message}</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api";
export default function Notes() {
const { id } = useParams();
const [notes, setNotes] = useState<any>(null);
const [tab, setTab] = useState<"dm" | "player">("dm");
useEffect(() => {
api.getNotes(id!).then(setNotes);
}, [id]);
if (!notes) return null;
return (
<div className="max-w-3xl mx-auto py-16 px-4">
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
<div className="flex gap-2 mb-6">
<button className={tab === "dm" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("dm")}>
DM notes
</button>
<button className={tab === "player" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("player")}>
Player recap
</button>
</div>
<div className="card p-6 whitespace-pre-wrap leading-relaxed">
{tab === "dm" ? notes.dm_notes : notes.player_recap}
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api, Job } from '../api';
function Toast({ message, onDone }: { message: string; onDone: () => void }) {
useEffect(() => { const t = setTimeout(onDone, 2500); return () => clearTimeout(t); }, [onDone]);
return (
<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">
{message}
</div>
);
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function ElapsedTimer({ createdAt, endedAt }: { createdAt: number; endedAt?: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (endedAt !== undefined) {
setElapsed(Math.floor(endedAt - createdAt));
return;
}
const tick = () => setElapsed(Math.floor(Date.now() / 1000 - createdAt));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [createdAt, endedAt]);
return <span className="font-mono text-xs">{formatDuration(elapsed)}</span>;
}
const STATUS_LABEL: Record<string, string> = {
uploaded: 'Ready to transcribe',
transcribing: 'Transcribing...',
transcribed: 'Ready to name speakers',
complete: 'Notes ready',
};
function JobStatusCard({ job, session, onRefresh, onDeleted }: { job: Job; session?: any; onRefresh: () => void; onDeleted: (strategy: string) => void }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [busy, setBusy] = useState(false);
const handleCancel = async () => {
if (!confirm('Are you sure you want to halt this job midway?')) return;
setBusy(true);
await api.cancelJob(job.id);
onRefresh();
setBusy(false);
};
const executeDeletion = async (strategy: 'all' | 'artifacts_only' | 'none') => {
setBusy(true);
await api.deleteJob(job.id, strategy);
setShowDeleteModal(false);
setBusy(false);
onDeleted(strategy);
};
const badgeClass =
job.status === 'done'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: job.status === 'error'
? 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 animate-pulse';
return (
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{job.job_type === 'transcribe' ? 'Transcription' : 'Note generation'}</h3>
<span className={`px-2.5 py-1 text-xs font-bold rounded-full uppercase ${badgeClass}`}>
{job.status === 'error' ? 'Failed' : job.status}
</span>
</div>
{job.status === 'error' ? (
<div className="bg-rose-950/40 border border-rose-900/50 text-rose-300 text-sm p-3 rounded mb-3 font-mono">
<strong>Error{job.error_stage ? ` [${job.error_stage}]` : ''}:</strong> {job.error || 'Unexpected error.'}
</div>
) : (
<p className="text-sm text-ink/60 mb-3">{job.progress || 'Waiting...'}</p>
)}
{job.status !== 'queued' && job.created_at && (
<div className="flex items-center gap-1 text-ink/40 mb-3">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<ElapsedTimer
createdAt={job.created_at}
endedAt={job.status === 'done' || job.status === 'error' ? job.updated_at : undefined}
/>
</div>
)}
{session?.audio_duration && (
<div className="text-xs text-ink/40 mb-3 font-mono">
Audio {formatDuration(session.audio_duration)}
{session.word_count ? ` \u2022 ${session.word_count.toLocaleString()} words` : ''}
{session.language ? ` \u2022 ${session.language}` : ''}
</div>
)}
<div className="flex gap-3">
{(job.status === 'queued' || job.status === 'running') && (
<button disabled={busy} onClick={handleCancel} className="btn-secondary text-sm">
Cancel
</button>
)}
{job.status === 'error' && job.job_type === 'transcribe' && (
<button
disabled={busy}
onClick={async () => { setBusy(true); await api.retryTranscription(job.session_id); setBusy(false); onRefresh(); }}
className="btn-primary text-sm"
>
Restart transcription
</button>
)}
<button disabled={busy} onClick={() => setShowDeleteModal(true)} className="btn-secondary text-sm ml-auto">
Delete...
</button>
</div>
{showDeleteModal && (
<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-2">Clean up session data?</h4>
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
Choose what to remove along with this job's tracking record.
</p>
<div className="space-y-2">
<button onClick={() => executeDeletion('all')} className="w-full text-left card p-3 text-sm">
🗑️ <strong>Wipe everything</strong>
<span className="block text-xs text-ink/40 mt-0.5">Session record, notes, transcript, and the source audio/video.</span>
</button>
<button onClick={() => executeDeletion('artifacts_only')} className="w-full text-left card p-3 text-sm">
📝 <strong>Keep source file</strong>
<span className="block text-xs text-ink/40 mt-0.5">Clears notes/transcript but keeps the original recording.</span>
</button>
<button onClick={() => executeDeletion('none')} className="w-full text-left card p-3 text-sm">
❌ <strong>Just clear this job</strong>
<span className="block text-xs text-ink/40 mt-0.5">Only removes the job tracking row. No files touched.</span>
</button>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => setShowDeleteModal(false)} className="text-sm text-ink/50 hover:text-ink">
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function SessionDetail() {
const { id } = useParams();
const nav = useNavigate();
const [session, setSession] = useState<any>(null);
const [job, setJob] = useState<Job | null>(null);
const [toast, setToast] = useState<string | null>(null);
const load = useCallback(() => {
if (!id) return;
api.getSession(id).then((s) => {
setSession(s);
setJob(s.latest_job || null);
});
}, [id]);
useEffect(load, [load]);
// Poll while a job is actively running so status/progress stay live.
useEffect(() => {
if (!job || (job.status !== 'queued' && job.status !== 'running')) return;
const interval = setInterval(load, 2500);
return () => clearInterval(interval);
}, [job, load]);
// Preload audio as soon as transcription finishes so it's buffered for the speakers page
useEffect(() => {
if (session?.status !== 'transcribed' || !id) return;
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'audio';
link.href = api.audioUrl(id);
document.head.appendChild(link);
return () => link.remove();
}, [session?.status, id]);
// Auto-redirect to speakers page once transcription completes
const autoRedirected = useRef(false);
useEffect(() => {
if (session?.status === 'transcribed') {
if (!autoRedirected.current) {
autoRedirected.current = true;
nav(`/sessions/${id}/speakers`);
}
} else {
autoRedirected.current = false;
}
}, [session?.status, id, nav]);
const retryTranscription = async () => {
if (!id) return;
await api.retryTranscription(id);
load();
};
const generateNotes = async () => {
if (!id) return;
await api.generateNotes(id);
load();
};
if (!session) return null;
return (
<div className="max-w-2xl mx-auto py-16 px-4">
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
<p className="text-brass mb-8">{STATUS_LABEL[session.status] ?? session.status}</p>
{job && (
<div className="mb-8">
<JobStatusCard job={job} session={session} onRefresh={load} onDeleted={(strategy) => {
if (strategy === 'all') {
setToast('Session wiped');
setTimeout(() => nav('/'), 600);
} else if (strategy === 'artifacts_only') {
setToast('Transcript and notes cleared — source file kept');
load();
} else {
setToast('Job record removed');
load();
}
}} />
</div>
)}
{session.status === 'uploaded' && !job && (
<button className="btn-primary" onClick={retryTranscription}>Start transcription</button>
)}
{session.status === 'complete' && (
<div className="mt-4 flex gap-3">
<button className="btn-secondary" onClick={generateNotes}>Regenerate notes</button>
<Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useState, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../api";
const STATUS_LABEL: Record<string, string> = {
uploaded: "Ready to transcribe",
transcribing: "Transcribing...",
transcribed: "Ready to name speakers",
complete: "Notes ready",
};
export default function Sessions() {
const [sessions, setSessions] = useState<any[]>([]);
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const nav = useNavigate();
const refresh = useCallback(() => api.listSessions().then(setSessions), []);
useEffect(() => {
refresh();
}, [refresh]);
// Poll while any session is actively processing
useEffect(() => {
const hasActive = sessions.some(s => s.status === 'transcribing');
if (!hasActive) return;
const interval = setInterval(refresh, 2500);
return () => clearInterval(interval);
}, [sessions, refresh]);
const upload = async () => {
if (!file || !name) return;
setUploadProgress(0);
const { session_id } = await api.createSession(name, file, setUploadProgress);
setUploadProgress(null);
nav(`/sessions/${session_id}`);
};
return (
<div className="max-w-3xl mx-auto py-16 px-4">
<div className="eyebrow mb-2">Nat20 Notes</div>
<h1 className="font-display text-4xl mb-8">Your Transcriptions</h1>
<div className="card p-6 mb-8">
<h2 className="font-display text-xl mb-4">New transcription</h2>
<div className="flex flex-col gap-3">
<input
className="input"
placeholder="Session name, e.g. Session 1 — The Frozen Crypt"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
type="file"
accept="audio/*,video/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="text-sm text-ink/70"
/>
<button className="btn-primary self-start" disabled={!file || !name || uploadProgress !== null} onClick={upload}>
{uploadProgress !== null ? `Uploading ${uploadProgress}%` : "Upload"}
</button>
</div>
</div>
<div className="space-y-3">
{sessions.length === 0 && (
<p className="text-ink/40">No sessions yet upload a recording to get started.</p>
)}
{sessions.map((s) => {
const job = s.latest_job;
const progress = job?.progress;
const displayStatus = s.status === 'transcribing' && progress
? progress
: (STATUS_LABEL[s.status] ?? s.status);
const statusColor = job?.status === 'error' ? 'text-rose-400' : 'text-brass';
return (
<Link key={s.id} to={`/sessions/${s.id}`} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent block">
<div>
<div className="font-medium">{s.name}</div>
<div className="text-xs text-ink/40 font-mono">{s.original_filename}</div>
</div>
<div className={"text-sm " + statusColor}>{displayStatus}</div>
</Link>
);
})}
</div>
<div className="mt-10 flex gap-4">
<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>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
export default function Settings() {
const [settings, setSettings] = useState<any>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.getSettings().then(setSettings);
}, []);
if (!settings) return null;
const set = (k: string, v: any) => setSettings({ ...settings, [k]: v });
const save = async () => {
await api.updateSettings(settings);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="max-w-xl mx-auto py-16 px-4">
<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>
<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>
{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>
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import { api, AppSettings } from '../api';
const DEFAULT_SETTINGS: AppSettings = {
onboarding_completed: false,
whisper_model: 'medium',
whisper_compute_type: 'int8',
hf_token: '',
llm_mode: 'ollama',
ollama_host: 'http://localhost:11434',
ollama_model: 'qwen2.5:7b',
api_base_url: 'https://api.openai.com/v1',
api_key: '',
api_model: 'gpt-4o-mini',
chunk_word_target: '2500',
world_context: '',
world_context_path: '',
};
export default function Setup({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState(1);
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
useEffect(() => {
api.getSettings().then(data => {
if (data) {
setSettings({ ...DEFAULT_SETTINGS, ...data });
}
}).catch(err => {
console.error('Failed to connect to settings endpoint:', err);
});
}, []);
const handleNext = () => setStep(prev => prev + 1);
const handleBack = () => setStep(prev => prev - 1);
const handleSave = async () => {
await api.saveSettings({ ...settings, onboarding_completed: true });
onComplete();
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 flex items-center justify-center p-6">
<div className="w-full max-w-2xl bg-slate-800 border border-slate-700 rounded-xl p-8 shadow-2xl">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-2xl font-bold tracking-tight text-teal-400">Nat20Notes Configuration Wizard</h1>
<span className="text-sm text-slate-400 font-mono">Step {step} of 2</span>
</div>
{step === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2">1. Audio & Diarization Engine</h2>
<p className="text-slate-400 text-sm mb-4">Set up your local Whisper sizing and secure deep speaker parsing models.</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Whisper Model Size</label>
<select
value={settings.whisper_model}
onChange={e => setSettings({ ...settings, whisper_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-white"
>
<option value="tiny">Tiny (~39M params)</option>
<option value="base">Base (~74M params)</option>
<option value="small">Small (~244M params)</option>
<option value="medium">Medium (~769M params)</option>
<option value="large-v3">Large V3 (~1.5B params - Recommended GPU)</option>
</select>
</div>
<div className="bg-slate-900 p-4 rounded border border-slate-700 space-y-3">
<label className="block text-sm font-medium">Hugging Face Access Token</label>
<input
type="password"
placeholder="hf_..."
value={settings.hf_token}
onChange={e => setSettings({ ...settings, hf_token: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white font-mono"
/>
<div className="text-xs text-slate-400 leading-relaxed">
💡 <strong>Diarization Requirements:</strong> To differentiate between multiple speakers, you must register a token:
<ol className="list-decimal list-inside ml-2 mt-1 space-y-1">
<li>Create an account on <a href="https://huggingface.co" target="_blank" rel="noreferrer" className="text-teal-400 underline">huggingface.co</a></li>
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/speaker-diarization-3.1" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/speaker-diarization-3.1</a></li>
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/segmentation-3.0" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/segmentation-3.0</a></li>
<li>Generate a <strong>Read</strong> token in Settings Access Tokens.</li>
</ol>
</div>
</div>
<div className="flex justify-end pt-4">
<button onClick={handleNext} className="bg-teal-600 hover:bg-teal-500 text-white px-5 py-2 rounded font-medium">
Continue Next
</button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2">2. Summarization Engine (LLM)</h2>
<p className="text-slate-400 text-sm mb-4">Choose where your post-transcription formatting and smart summaries are generated.</p>
</div>
<div className="flex gap-4 border-b border-slate-700 pb-4">
{(['ollama', 'api'] as const).map(mode => (
<button
key={mode}
onClick={() => setSettings({ ...settings, llm_mode: mode })}
className={`px-4 py-2 rounded uppercase text-xs font-bold tracking-wider ${settings.llm_mode === mode ? 'bg-teal-600 text-white' : 'bg-slate-900 text-slate-400'}`}
>
{mode === 'ollama' ? 'Local (Ollama)' : 'Hosted API'}
</button>
))}
</div>
{settings.llm_mode === 'ollama' ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Ollama Host Address</label>
<input
type="text"
value={settings.ollama_host}
onChange={e => setSettings({ ...settings, ollama_host: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Model Name</label>
<input
type="text"
placeholder="qwen2.5:7b"
value={settings.ollama_model}
onChange={e => setSettings({ ...settings, ollama_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Must match your local CLI entry for running "ollama pull &lt;model&gt;".</p>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">API Base URL</label>
<input
type="text"
value={settings.api_base_url}
onChange={e => setSettings({ ...settings, api_base_url: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">API Key</label>
<input
type="password"
value={settings.api_key}
onChange={e => setSettings({ ...settings, api_key: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-white"
placeholder="sk-..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Model Name</label>
<input
type="text"
value={settings.api_model}
onChange={e => setSettings({ ...settings, api_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
</div>
</div>
)}
<div className="border-t border-slate-700 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-300">Campaign context (optional)</h3>
<textarea
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 h-24"
placeholder="Paste world/campaign context directly..."
value={settings.world_context}
onChange={e => setSettings({ ...settings, world_context: e.target.value })}
/>
<input
type="text"
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-sm"
placeholder="...or point to a local file"
value={settings.world_context_path}
onChange={e => setSettings({ ...settings, world_context_path: e.target.value })}
/>
</div>
<div className="flex justify-between pt-4 border-t border-slate-700">
<button onClick={handleBack} className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded font-medium">
Back
</button>
<button onClick={handleSave} className="bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2 rounded font-medium">
Complete Setup & Launch
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { SessionReel, colorFor } from "../components/SessionReel";
export default function Speakers() {
const { id } = useParams();
const nav = useNavigate();
const [speakers, setSpeakers] = useState<any[]>([]);
const [turns, setTurns] = useState<any[]>([]);
const [names, setNames] = useState<Record<string, string>>({});
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [mergeTarget, setMergeTarget] = useState("");
const [busy, setBusy] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const paletteRef = useRef(new Map<string, string>());
const load = () => {
api.getSpeakers(id!).then((sp) => {
setSpeakers(sp);
const n: Record<string, string> = {};
sp.forEach((s: any) => (n[s.raw_label] = s.display_name || ""));
setNames(n);
});
api.getTurns(id!).then(setTurns);
};
useEffect(load, [id]);
const save = async (raw_label: string) => {
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
};
const playAt = (t: number) => {
const audio = audioRef.current;
if (!audio) return;
if (audio.readyState < 2) return;
audio.pause();
audio.currentTime = t;
const p = audio.play();
if (p) p.catch(() => {});
};
const toggleSelected = (label: string) => {
const next = new Set(selected);
if (next.has(label)) next.delete(label); else next.add(label);
setSelected(next);
};
const doMerge = async () => {
if (!mergeTarget || selected.size < 2) return;
setBusy(true);
const sourceLabels = [...selected].filter(l => l !== mergeTarget);
await fetch(`/api/sessions/${id}/speakers/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target_label: mergeTarget, source_labels: sourceLabels }),
});
setSelected(new Set());
setMergeTarget("");
setBusy(false);
load();
};
const handleDone = async () => {
setBusy(true);
await api.generateNotes(id!);
setBusy(false);
nav(`/sessions/${id}`);
};
const selectedArr = [...selected];
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">Name your speakers</h1>
<p className="text-ink/60 mb-6">Click anywhere on the reel to jump to that moment and hear who's talking.</p>
<audio
ref={audioRef}
src={api.audioUrl(id!)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
controls
className="w-full mb-3"
/>
<div className="mb-8">
<SessionReel turns={turns} duration={duration} currentTime={currentTime} onSeek={playAt} />
</div>
{selected.size >= 2 && (
<div className="card p-4 mb-6 flex items-center gap-3 flex-wrap">
<span className="text-sm text-ink/60">{selected.size} selected</span>
<select
className="input text-sm flex-1 min-w-[200px]"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
>
<option value="">Merge into...</option>
{selectedArr.map((l) => (
<option key={l} value={l}>{l} {names[l] ? `(${names[l]})` : ""}</option>
))}
</select>
<button className="btn-primary text-sm" disabled={!mergeTarget || busy} onClick={doMerge}>
{busy ? "Merging..." : "Merge"}
</button>
<button className="btn-secondary text-sm" onClick={() => { setSelected(new Set()); setMergeTarget(""); }}>
Cancel
</button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{speakers.map((sp) => (
<div key={sp.raw_label} className={"card p-4 " + (selected.has(sp.raw_label) ? "ring-2 ring-brass" : "")}>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={selected.has(sp.raw_label)}
onChange={() => toggleSelected(sp.raw_label)}
className="accent-brass"
/>
<span
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
style={{ background: colorFor(sp.raw_label, paletteRef.current) }}
/>
<span className="font-mono text-xs text-ink/40">{sp.raw_label}</span>
</div>
<input
className="input w-full mb-3"
placeholder="Player or character name"
value={names[sp.raw_label] || ""}
onChange={(e) => setNames({ ...names, [sp.raw_label]: e.target.value })}
onBlur={() => save(sp.raw_label)}
/>
<div className="space-y-1">
{sp.samples.map((s: any, i: number) => (
<button
key={i}
onClick={() => playAt(s.start)}
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate"
>
{s.text}
</button>
))}
</div>
</div>
))}
</div>
<div className="mt-8">
<button className="btn-primary" onClick={() => setShowConfirm(true)}>
Done naming
</button>
</div>
{showConfirm && (
<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-2">Generate notes now?</h4>
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
This will use your speaker names to create session notes. Are you sure?
</p>
<div className="flex gap-3 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancel
</button>
<button disabled={busy} onClick={handleDone} className="btn-primary">
{busy ? "Starting..." : "Yes, generate notes"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

32
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
::selection {
background: #C9A227;
color: #14171F;
}
:focus-visible {
outline: 2px solid #C9A227;
outline-offset: 2px;
}
}
@layer components {
.card {
@apply bg-panel border border-white/5 rounded-lg;
}
.btn-primary {
@apply bg-brass text-deep font-medium px-4 py-2 rounded-md hover:brightness-110 transition disabled:opacity-40 disabled:pointer-events-none;
}
.btn-secondary {
@apply bg-panel2 text-ink font-medium px-4 py-2 rounded-md border border-white/10 hover:border-brass/50 transition;
}
.input {
@apply bg-deep border border-white/10 rounded-md px-3 py-2 text-ink placeholder:text-ink/30 focus:border-brass/60;
}
.eyebrow {
@apply font-mono text-xs uppercase tracking-widest text-brass/80;
}
}

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
deep: "#14171F",
panel: "#1E2230",
panel2: "#262B3D",
ink: "#EDE6D6",
inkdim: "#A9A4956e".slice(0, 7),
brass: "#C9A227",
ember: "#B4523A",
moss: "#5F7A61",
dusk: "#4E6A87",
plum: "#7A5670",
},
fontFamily: {
display: ["Fraunces", "Georgia", "serif"],
body: ["Inter", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "monospace"],
},
},
},
plugins: [],
};

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});