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

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>
);
}