- transcriptionStore: replace all supabase.auth.getUser() with getSession() so session restoration on page refresh does not race against GoTrue network validation - CCFilesPanel: remove selectedCabinet from loadCabinets useCallback deps; use initialSelectionDone ref to prevent double-call on first mount - CCTranscriptionPanel: replace hardcoded LAN IP with VITE_API_URL env var Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1079 lines
42 KiB
TypeScript
1079 lines
42 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
||
import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material";
|
||
import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore";
|
||
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
|
||
import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger";
|
||
import LLMConfigModal from "./LLMConfigModal";
|
||
import "./panel.css";
|
||
|
||
const formatTime = (seconds: number): string => {
|
||
const m = Math.floor(seconds / 60);
|
||
const s = seconds % 60;
|
||
return m.toString().padStart(2, "0") + ":" + s.toString().padStart(2, "0");
|
||
};
|
||
|
||
const formatDateTime = (isoString: string): string => {
|
||
const date = new Date(isoString);
|
||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
};
|
||
|
||
const formatSrtTime = (seconds: number): string => {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
const ms = Math.round((seconds % 1) * 1000);
|
||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
||
};
|
||
|
||
const downloadBlob = (content: string, filename: string, mimeType: string) => {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
};
|
||
|
||
type TabType = "live" | "sessions" | "keywords";
|
||
|
||
const SUMMARY_TYPES = [
|
||
{ value: 'full_lesson', label: 'Full Lesson Summary' },
|
||
{ value: 'questions_asked', label: 'Questions Asked' },
|
||
{ value: 'teaching_style', label: 'Teaching Style' },
|
||
{ value: 'key_moments', label: 'Key Moments' },
|
||
{ value: 'segment', label: 'Segment Summary' },
|
||
] as const;
|
||
|
||
export const CCTranscriptionPanel: React.FC = () => {
|
||
const {
|
||
isRecording,
|
||
completedSegments,
|
||
serverWindow,
|
||
currentSegment,
|
||
wordCount,
|
||
elapsedSeconds,
|
||
activeSession,
|
||
timetableContext,
|
||
startSession,
|
||
stopSession,
|
||
updateServerWindow,
|
||
resetSession,
|
||
tickElapsed,
|
||
addCanvasEvent,
|
||
flushCanvasEvents,
|
||
loadSessions,
|
||
setTimetableContext,
|
||
llmConfig,
|
||
summaryText,
|
||
isGeneratingSummary,
|
||
summaryError,
|
||
setSummaryText,
|
||
setIsGeneratingSummary,
|
||
setSummaryError,
|
||
keywordWatches,
|
||
keywordMatches,
|
||
loadKeywordWatches,
|
||
addKeywordWatch,
|
||
deleteKeywordWatch,
|
||
checkSegmentForKeywords,
|
||
clearKeywordMatches,
|
||
} = useTranscriptionStore();
|
||
|
||
const [activeTab, setActiveTab] = useState<TabType>("live");
|
||
const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments');
|
||
const [sessions, setSessions] = useState<TranscriptionSession[]>([]);
|
||
const [sessionName, setSessionName] = useState("Untitled Session");
|
||
const serviceRef = useRef<TranscriptionService | null>(null);
|
||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const canvasLoggerRef = useRef<CanvasEventLogger | null>(null);
|
||
|
||
// Modal state
|
||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||
const [showSummaryModal, setShowSummaryModal] = useState(false);
|
||
const [summaryType, setSummaryType] = useState('full_lesson');
|
||
|
||
// Keyword tab state
|
||
const [newKeyword, setNewKeyword] = useState('');
|
||
const [isAddingKeyword, setIsAddingKeyword] = useState(false);
|
||
|
||
// Load sessions and keyword watches on mount
|
||
useEffect(() => {
|
||
loadSessions().then(setSessions);
|
||
loadKeywordWatches();
|
||
}, []);
|
||
|
||
// Auto-detect timetable context on mount
|
||
useEffect(() => {
|
||
const detectTimetable = async () => {
|
||
try {
|
||
const apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai';
|
||
const response = await fetch(`${apiBase}/database/timetables/current-period`);
|
||
const data = await response.json();
|
||
if (data.period_id) {
|
||
setTimetableContext(data as TimetablePeriod);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to detect timetable context:', error);
|
||
}
|
||
};
|
||
detectTimetable();
|
||
}, []);
|
||
|
||
// Timer for elapsed seconds
|
||
useEffect(() => {
|
||
if (isRecording) {
|
||
timerRef.current = setInterval(tickElapsed, 1000);
|
||
} else if (timerRef.current) {
|
||
clearInterval(timerRef.current);
|
||
timerRef.current = null;
|
||
}
|
||
return () => {
|
||
if (timerRef.current) clearInterval(timerRef.current);
|
||
};
|
||
}, [isRecording, tickElapsed]);
|
||
|
||
// Canvas event flush interval (every 5s)
|
||
useEffect(() => {
|
||
if (!isRecording) return;
|
||
|
||
const flushInterval = setInterval(async () => {
|
||
await flushCanvasEvents();
|
||
}, 5000);
|
||
|
||
return () => clearInterval(flushInterval);
|
||
}, [isRecording, flushCanvasEvents]);
|
||
|
||
const handleStart = async () => {
|
||
try {
|
||
await startSession(timetableContext || undefined);
|
||
const service = new TranscriptionService();
|
||
service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => {
|
||
updateServerWindow(segs, isLastLive);
|
||
});
|
||
service.setDisconnectCallback(() => {
|
||
console.warn('[CCTranscriptionPanel] WebSocket disconnected unexpectedly — resetting session');
|
||
serviceRef.current = null;
|
||
stopSession();
|
||
});
|
||
const whisperModel = useTranscriptionStore.getState().llmConfig.whisperModel || 'large-v3';
|
||
await service.startTranscription({ modelSize: whisperModel });
|
||
serviceRef.current = service;
|
||
|
||
// Initialize canvas event logger if session was created
|
||
const state = useTranscriptionStore.getState();
|
||
if (state.activeSession) {
|
||
console.log('[CCTranscriptionPanel] Canvas event logging would start for session', state.activeSession.id);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to start transcription:", error);
|
||
stopSession();
|
||
}
|
||
};
|
||
|
||
const handleStop = async () => {
|
||
if (serviceRef.current) {
|
||
serviceRef.current.stopTranscription();
|
||
serviceRef.current = null;
|
||
}
|
||
|
||
if (canvasLoggerRef.current) {
|
||
canvasLoggerRef.current.detach();
|
||
canvasLoggerRef.current = null;
|
||
}
|
||
|
||
await stopSession();
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (serviceRef.current) {
|
||
serviceRef.current.stopTranscription();
|
||
serviceRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const handleRefreshSessions = async () => {
|
||
const loaded = await loadSessions();
|
||
setSessions(loaded);
|
||
};
|
||
|
||
// Generate summary — calls LLM providers directly, no backend proxy needed
|
||
const handleGenerateSummary = async () => {
|
||
const config = useTranscriptionStore.getState().llmConfig;
|
||
const allSegs = completedSegments;
|
||
|
||
if (allSegs.length === 0) {
|
||
setSummaryError("No transcription segments to summarise yet.");
|
||
return;
|
||
}
|
||
if (!config.model) {
|
||
setSummaryError("Please configure an LLM model in Settings first.");
|
||
return;
|
||
}
|
||
|
||
setIsGeneratingSummary(true);
|
||
setSummaryError(null);
|
||
setShowSummaryModal(false);
|
||
|
||
const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' ');
|
||
|
||
const promptMap: Record<string, string> = {
|
||
full_lesson: `You are an assistant helping a teacher review a lesson. Below is the full transcript of a lesson. Write a concise summary covering the main topics taught, key activities, and any notable moments.\n\nTranscript:\n${transcript}`,
|
||
questions_asked: `Below is a classroom transcript. List all the questions that were asked during the lesson, formatted as a numbered list.\n\nTranscript:\n${transcript}`,
|
||
teaching_style: `Below is a classroom transcript. Briefly describe the teaching style observed: instructional approach, student engagement techniques, pacing, and tone.\n\nTranscript:\n${transcript}`,
|
||
key_moments: `Below is a classroom transcript. Identify the 3–5 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
|
||
segment: `Summarise the following classroom transcript in 2–3 sentences.\n\nTranscript:\n${transcript}`,
|
||
};
|
||
|
||
const prompt = promptMap[summaryType] || promptMap.full_lesson;
|
||
|
||
try {
|
||
let summaryResult = '';
|
||
|
||
if (config.provider === 'ollama') {
|
||
const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, '');
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
if (config.apiKey && config.apiKey !== 'sk-dummy') headers['Authorization'] = `Bearer ${config.apiKey}`;
|
||
const res = await fetch(`${base}/api/chat`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
model: config.model,
|
||
messages: [{ role: 'user', content: prompt }],
|
||
stream: false,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error(`Ollama error ${res.status}: ${await res.text()}`);
|
||
const d = await res.json();
|
||
summaryResult = d.message?.content ?? JSON.stringify(d);
|
||
|
||
} else if (config.provider === 'openai' || config.provider === 'openrouter') {
|
||
const base = config.provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1';
|
||
const res = await fetch(`${base}/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` },
|
||
body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }] }),
|
||
});
|
||
if (!res.ok) throw new Error(`${config.provider} error ${res.status}: ${await res.text()}`);
|
||
const d = await res.json();
|
||
summaryResult = d.choices?.[0]?.message?.content ?? JSON.stringify(d);
|
||
|
||
} else if (config.provider === 'anthropic') {
|
||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': config.apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
},
|
||
body: JSON.stringify({ model: config.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
|
||
});
|
||
if (!res.ok) throw new Error(`Anthropic error ${res.status}: ${await res.text()}`);
|
||
const d = await res.json();
|
||
summaryResult = d.content?.[0]?.text ?? JSON.stringify(d);
|
||
|
||
} else if (config.provider === 'google') {
|
||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
|
||
});
|
||
if (!res.ok) throw new Error(`Google error ${res.status}: ${await res.text()}`);
|
||
const d = await res.json();
|
||
summaryResult = d.candidates?.[0]?.content?.parts?.[0]?.text ?? JSON.stringify(d);
|
||
|
||
} else {
|
||
throw new Error(`Unknown provider: ${config.provider}`);
|
||
}
|
||
|
||
setSummaryText(summaryResult);
|
||
} catch (error) {
|
||
console.error('Failed to generate summary:', error);
|
||
setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary');
|
||
} finally {
|
||
setIsGeneratingSummary(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="panel-container">
|
||
{/* Tab bar */}
|
||
<div style={{ display: "flex", borderBottom: "1px solid var(--color-divider)", marginBottom: "8px" }}>
|
||
<button
|
||
onClick={() => setActiveTab("live")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px",
|
||
border: "none",
|
||
backgroundColor: activeTab === "live" ? "var(--color-hover)" : "transparent",
|
||
color: "var(--color-text)",
|
||
cursor: "pointer",
|
||
fontSize: "13px",
|
||
fontWeight: activeTab === "live" ? 600 : 400,
|
||
borderBottom: activeTab === "live" ? "2px solid var(--color-text)" : "none",
|
||
}}
|
||
>
|
||
<HistoryIcon style={{ fontSize: "16px", verticalAlign: "middle", marginRight: "4px" }} />
|
||
Live
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("sessions")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px",
|
||
border: "none",
|
||
backgroundColor: activeTab === "sessions" ? "var(--color-hover)" : "transparent",
|
||
color: "var(--color-text)",
|
||
cursor: "pointer",
|
||
fontSize: "13px",
|
||
fontWeight: activeTab === "sessions" ? 600 : 400,
|
||
borderBottom: activeTab === "sessions" ? "2px solid var(--color-text)" : "none",
|
||
}}
|
||
>
|
||
<HistoryIcon style={{ fontSize: "16px", verticalAlign: "middle", marginRight: "4px" }} />
|
||
Sessions
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("keywords")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "8px",
|
||
border: "none",
|
||
backgroundColor: activeTab === "keywords" ? "var(--color-hover)" : "transparent",
|
||
color: keywordMatches.length > 0 ? "#f59e0b" : "var(--color-text)",
|
||
cursor: "pointer",
|
||
fontSize: "13px",
|
||
fontWeight: activeTab === "keywords" ? 600 : 400,
|
||
borderBottom: activeTab === "keywords" ? "2px solid var(--color-text)" : "none",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<KeywordIcon style={{ fontSize: "16px", verticalAlign: "middle", marginRight: "4px" }} />
|
||
Keywords
|
||
{keywordMatches.length > 0 && (
|
||
<span style={{
|
||
marginLeft: "4px",
|
||
backgroundColor: "#f59e0b",
|
||
color: "#fff",
|
||
borderRadius: "10px",
|
||
padding: "1px 5px",
|
||
fontSize: "10px",
|
||
fontWeight: 700,
|
||
}}>
|
||
{keywordMatches.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Live Tab */}
|
||
{activeTab === "live" && (
|
||
<>
|
||
{/* Session header with settings button */}
|
||
<div className="panel-section">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)" }}>
|
||
{sessionName}
|
||
</span>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||
<button
|
||
onClick={() => setShowSettingsModal(true)}
|
||
title="LLM Provider Settings"
|
||
style={{
|
||
padding: "4px",
|
||
border: "none",
|
||
backgroundColor: "transparent",
|
||
color: "var(--color-text-2)",
|
||
cursor: "pointer",
|
||
borderRadius: "4px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--color-text)")}
|
||
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--color-text-2)")}
|
||
>
|
||
<SettingsIcon sx={{ fontSize: 18 }} />
|
||
</button>
|
||
<div style={{ display: "flex", gap: "12px", fontSize: "12px", color: "var(--color-text-2)" }}>
|
||
<span>{wordCount} words</span>
|
||
<span>{formatTime(elapsedSeconds)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timetable badge */}
|
||
{timetableContext && timetableContext.event_label && (
|
||
<div style={{
|
||
marginTop: "8px",
|
||
padding: "4px 8px",
|
||
backgroundColor: "var(--color-hover)",
|
||
borderRadius: "4px",
|
||
fontSize: "12px",
|
||
color: "var(--color-text)",
|
||
}}>
|
||
📅 {timetableContext.event_label}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="panel-divider" />
|
||
|
||
{/* Record button */}
|
||
<div className="panel-section">
|
||
<button
|
||
onClick={isRecording ? handleStop : handleStart}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: "8px",
|
||
padding: "16px",
|
||
width: "100%",
|
||
borderRadius: "8px",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: "14px",
|
||
fontWeight: 600,
|
||
color: "#fff",
|
||
backgroundColor: isRecording ? "#ef4444" : "#2563eb",
|
||
transition: "background-color 200ms ease",
|
||
}}
|
||
>
|
||
{isRecording ? <StopIcon /> : <MicIcon />}
|
||
{isRecording ? "Stop Recording" : "Start Recording"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Generate Summary button (only when recording or has segments) */}
|
||
{(isRecording || completedSegments.length > 0) && (
|
||
<>
|
||
<div className="panel-divider" />
|
||
<div className="panel-section">
|
||
<button
|
||
onClick={() => setShowSummaryModal(true)}
|
||
disabled={isGeneratingSummary}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: "8px",
|
||
padding: "12px",
|
||
width: "100%",
|
||
borderRadius: "8px",
|
||
border: "none",
|
||
cursor: isGeneratingSummary ? "not-allowed" : "pointer",
|
||
fontSize: "13px",
|
||
fontWeight: 600,
|
||
color: "#fff",
|
||
backgroundColor: isGeneratingSummary ? "#9ca3af" : "#7c3aed",
|
||
transition: "background-color 200ms ease",
|
||
opacity: isGeneratingSummary ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{isGeneratingSummary ? (
|
||
<span style={{ display: "inline-block", width: 16, height: 16, border: "2px solid #fff", borderTopColor: "transparent", borderRadius: "50%", animation: "spin 1s linear infinite" }} />
|
||
) : (
|
||
<AutoAwesomeIcon />
|
||
)}
|
||
{isGeneratingSummary ? "Generating..." : "Generate Summary"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Export button — available whenever there are completed segments */}
|
||
{completedSegments.length > 0 && (
|
||
<>
|
||
<div className="panel-divider" />
|
||
<div className="panel-section">
|
||
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
|
||
Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
|
||
</div>
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
{(['srt', 'txt', 'json'] as const).map((format) => (
|
||
<button
|
||
key={format}
|
||
onClick={() => {
|
||
// Build the segment list from store state — always matches what's displayed.
|
||
const allSegs = [
|
||
...completedSegments,
|
||
...(currentSegment && currentSegment.text.trim() ? [{ ...currentSegment, isFinal: true }] : []),
|
||
];
|
||
const sessionTag = activeSession?.id.slice(0, 8) ?? 'session';
|
||
const filename = `${sessionTag}.${format}`;
|
||
|
||
if (format === 'srt') {
|
||
const content = allSegs
|
||
.filter(s => s.text.trim())
|
||
.map((seg, i) =>
|
||
`${i + 1}\n${formatSrtTime(seg.start)} --> ${formatSrtTime(seg.end)}\n${seg.text.trim()}\n`
|
||
)
|
||
.join('\n');
|
||
downloadBlob(content, filename, 'text/plain');
|
||
} else if (format === 'txt') {
|
||
const content = allSegs.map(s => s.text.trim()).filter(Boolean).join('\n');
|
||
downloadBlob(content, filename, 'text/plain');
|
||
} else {
|
||
const content = JSON.stringify({
|
||
exported_at: new Date().toISOString(),
|
||
segment_count: allSegs.length,
|
||
segments: allSegs.map(s => ({
|
||
start: s.start,
|
||
end: s.end,
|
||
text: s.text.trim(),
|
||
})),
|
||
}, null, 2);
|
||
downloadBlob(content, filename, 'application/json');
|
||
}
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
padding: '8px',
|
||
border: '1px solid var(--color-divider)',
|
||
backgroundColor: 'transparent',
|
||
color: 'var(--color-text)',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
fontSize: '12px',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{format.toUpperCase()}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Summary result display */}
|
||
{summaryText && (
|
||
<>
|
||
<div className="panel-divider" />
|
||
<div className="panel-section">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<div className="panel-section-title">
|
||
<AutoAwesomeIcon sx={{ fontSize: 16, verticalAlign: "middle", marginRight: "4px" }} />
|
||
AI Summary
|
||
</div>
|
||
<button
|
||
onClick={() => setSummaryText(null)}
|
||
style={{
|
||
padding: "2px 6px",
|
||
border: "none",
|
||
backgroundColor: "transparent",
|
||
color: "var(--color-text-2)",
|
||
cursor: "pointer",
|
||
fontSize: "16px",
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div style={{
|
||
padding: "10px",
|
||
backgroundColor: "#f5f3ff",
|
||
borderRadius: "6px",
|
||
border: "1px solid #e0d5f5",
|
||
fontSize: "13px",
|
||
color: "var(--color-text)",
|
||
lineHeight: 1.5,
|
||
whiteSpace: "pre-wrap",
|
||
maxHeight: "300px",
|
||
overflowY: "auto",
|
||
}}>
|
||
{summaryText}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Summary error display */}
|
||
{summaryError && (
|
||
<>
|
||
<div className="panel-divider" />
|
||
<div className="panel-section">
|
||
<div style={{
|
||
padding: "10px",
|
||
backgroundColor: "#fef2f2",
|
||
borderRadius: "6px",
|
||
border: "1px solid #fecaca",
|
||
fontSize: "13px",
|
||
color: "#dc2626",
|
||
}}>
|
||
<strong>Error:</strong> {summaryError}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="panel-divider" />
|
||
|
||
{/* Live feed */}
|
||
<div className="panel-section" style={{ gap: "6px" }}>
|
||
{/* Header row: title + view mode toggle */}
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||
<div className="panel-section-title" style={{ margin: 0 }}>Live Feed</div>
|
||
<div style={{ display: "flex", gap: "3px" }}>
|
||
{(["segments", "transcript"] as const).map(mode => (
|
||
<button
|
||
key={mode}
|
||
onClick={() => setViewMode(mode)}
|
||
style={{
|
||
padding: "2px 8px",
|
||
fontSize: "11px",
|
||
backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)",
|
||
color: viewMode === mode ? "#fff" : "var(--color-text-2)",
|
||
border: "1px solid var(--color-divider)",
|
||
borderRadius: "3px",
|
||
cursor: "pointer",
|
||
textTransform: "capitalize",
|
||
}}
|
||
>
|
||
{mode}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{(() => {
|
||
const allFinal = completedSegments; // already merged from server on every message
|
||
|
||
if (viewMode === "segments") {
|
||
return (
|
||
<>
|
||
{allFinal.map((seg, i) => (
|
||
<div
|
||
key={"seg-" + i}
|
||
style={{
|
||
padding: "7px 10px",
|
||
backgroundColor: "var(--color-muted)",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--color-divider)",
|
||
}}
|
||
>
|
||
<div style={{
|
||
fontFamily: "monospace",
|
||
fontSize: "10px",
|
||
color: "var(--color-text-2)",
|
||
marginBottom: "3px",
|
||
letterSpacing: "0.03em",
|
||
}}>
|
||
{formatTime(Math.floor(seg.start))} → {formatTime(Math.floor(seg.end))}
|
||
</div>
|
||
<div style={{ fontSize: "13px", color: "var(--color-text)", lineHeight: 1.5 }}>
|
||
{seg.text}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{currentSegment && (
|
||
<div style={{
|
||
padding: "7px 10px",
|
||
backgroundColor: "var(--color-panel)",
|
||
borderRadius: "6px",
|
||
border: "1px dashed var(--color-divider)",
|
||
}}>
|
||
<div style={{
|
||
fontFamily: "monospace",
|
||
fontSize: "10px",
|
||
color: "var(--color-text-3)",
|
||
marginBottom: "3px",
|
||
letterSpacing: "0.03em",
|
||
}}>
|
||
{formatTime(Math.floor(currentSegment.start))} → …
|
||
</div>
|
||
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
|
||
{currentSegment.text || "Listening…"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Transcript view — single joined box + separate live segment
|
||
const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" ");
|
||
return (
|
||
<>
|
||
{(joinedText || !currentSegment) && (
|
||
<div style={{
|
||
padding: "10px 12px",
|
||
backgroundColor: "var(--color-muted)",
|
||
borderRadius: "6px",
|
||
border: "1px solid var(--color-divider)",
|
||
fontSize: "13px",
|
||
color: "var(--color-text)",
|
||
lineHeight: 1.7,
|
||
minHeight: "48px",
|
||
}}>
|
||
{joinedText || <span style={{ color: "var(--color-text-2)" }}>No completed segments yet.</span>}
|
||
</div>
|
||
)}
|
||
|
||
{currentSegment && (
|
||
<div style={{
|
||
padding: "7px 10px",
|
||
backgroundColor: "var(--color-panel)",
|
||
borderRadius: "6px",
|
||
border: "1px dashed var(--color-divider)",
|
||
}}>
|
||
<div style={{
|
||
fontFamily: "monospace",
|
||
fontSize: "10px",
|
||
color: "var(--color-text-3)",
|
||
marginBottom: "3px",
|
||
}}>
|
||
{formatTime(Math.floor(currentSegment.start))} → …
|
||
</div>
|
||
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
|
||
{currentSegment.text || "Listening…"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
{!isRecording && completedSegments.length === 0 && !currentSegment && (
|
||
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
|
||
Press Start Recording to begin transcription
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Sessions Tab */}
|
||
{activeTab === "sessions" && (
|
||
<>
|
||
<div className="panel-section">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)" }}>
|
||
Past Sessions
|
||
</span>
|
||
<button
|
||
onClick={handleRefreshSessions}
|
||
style={{
|
||
padding: "4px 8px",
|
||
border: "1px solid var(--color-divider)",
|
||
backgroundColor: "transparent",
|
||
color: "var(--color-text)",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
fontSize: "12px",
|
||
}}
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel-divider" />
|
||
|
||
<div className="panel-section" style={{ gap: "8px", maxHeight: "400px", overflowY: "auto" }}>
|
||
{sessions.length === 0 ? (
|
||
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
|
||
No sessions yet
|
||
</div>
|
||
) : (
|
||
sessions.map((session) => (
|
||
<div
|
||
key={session.id}
|
||
style={{
|
||
padding: "10px",
|
||
backgroundColor: "var(--color-muted)",
|
||
borderRadius: "4px",
|
||
border: "1px solid var(--color-divider)",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: "13px", fontWeight: 500, color: "var(--color-text)" }}>
|
||
{session.title || "Untitled Session"}
|
||
</div>
|
||
<div style={{ fontSize: "12px", color: "var(--color-text-2)", marginTop: "4px" }}>
|
||
{formatDateTime(session.started_at)}
|
||
{session.duration_seconds && ` · ${Math.floor(session.duration_seconds / 60)}m`}
|
||
{session.segment_count && ` · ${session.segment_count} segments`}
|
||
</div>
|
||
{session.timetable_event_label && (
|
||
<div style={{
|
||
marginTop: "4px",
|
||
padding: "2px 6px",
|
||
backgroundColor: "var(--color-hover)",
|
||
borderRadius: "3px",
|
||
fontSize: "11px",
|
||
color: "var(--color-text)",
|
||
display: "inline-block",
|
||
}}>
|
||
📅 {session.timetable_event_label}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Keywords Tab */}
|
||
{activeTab === "keywords" && (
|
||
<>
|
||
<div className="panel-section">
|
||
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
|
||
Keyword Watches
|
||
</div>
|
||
<div style={{ display: "flex", gap: "6px" }}>
|
||
<input
|
||
type="text"
|
||
value={newKeyword}
|
||
onChange={(e) => setNewKeyword(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && newKeyword.trim()) {
|
||
setIsAddingKeyword(true);
|
||
addKeywordWatch(newKeyword.trim()).finally(() => {
|
||
setNewKeyword('');
|
||
setIsAddingKeyword(false);
|
||
});
|
||
}
|
||
}}
|
||
placeholder="Add keyword..."
|
||
style={{
|
||
flex: 1,
|
||
padding: "6px 8px",
|
||
border: "1px solid var(--color-divider)",
|
||
borderRadius: "4px",
|
||
fontSize: "13px",
|
||
backgroundColor: "var(--color-panel)",
|
||
color: "var(--color-text)",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (!newKeyword.trim() || isAddingKeyword) return;
|
||
setIsAddingKeyword(true);
|
||
addKeywordWatch(newKeyword.trim()).finally(() => {
|
||
setNewKeyword('');
|
||
setIsAddingKeyword(false);
|
||
});
|
||
}}
|
||
disabled={!newKeyword.trim() || isAddingKeyword}
|
||
style={{
|
||
padding: "6px 10px",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
backgroundColor: newKeyword.trim() ? "#2563eb" : "var(--color-divider)",
|
||
color: "#fff",
|
||
cursor: newKeyword.trim() ? "pointer" : "not-allowed",
|
||
fontSize: "13px",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel-divider" />
|
||
|
||
<div className="panel-section" style={{ gap: "6px" }}>
|
||
{keywordWatches.length === 0 ? (
|
||
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "12px", fontSize: "13px" }}>
|
||
No keyword watches yet
|
||
</div>
|
||
) : (
|
||
keywordWatches.filter((w) => w.is_active).map((watch) => (
|
||
<div
|
||
key={watch.id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "6px 10px",
|
||
backgroundColor: "var(--color-muted)",
|
||
borderRadius: "4px",
|
||
border: "1px solid var(--color-divider)",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: "13px", color: "var(--color-text)", fontFamily: "monospace" }}>
|
||
{watch.keyword}
|
||
</span>
|
||
<button
|
||
onClick={() => deleteKeywordWatch(watch.id)}
|
||
style={{
|
||
padding: "2px",
|
||
border: "none",
|
||
backgroundColor: "transparent",
|
||
color: "var(--color-text-2)",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<Close sx={{ fontSize: 14 }} />
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{keywordMatches.length > 0 && (
|
||
<>
|
||
<div className="panel-divider" />
|
||
<div className="panel-section">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "6px" }}>
|
||
<div style={{ fontSize: "13px", fontWeight: 500, color: "var(--color-text)" }}>
|
||
Matches this session
|
||
</div>
|
||
<button
|
||
onClick={clearKeywordMatches}
|
||
style={{
|
||
padding: "2px 6px",
|
||
border: "none",
|
||
backgroundColor: "transparent",
|
||
color: "var(--color-text-2)",
|
||
cursor: "pointer",
|
||
fontSize: "11px",
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", maxHeight: "250px", overflowY: "auto" }}>
|
||
{[...keywordMatches].reverse().map((match, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
padding: "6px 8px",
|
||
backgroundColor: "#fffbeb",
|
||
borderRadius: "4px",
|
||
border: "1px solid #fde68a",
|
||
fontSize: "12px",
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 600, color: "#92400e", marginBottom: "2px" }}>
|
||
"{match.keyword}" at {formatTime(Math.floor(match.elapsed_seconds))}
|
||
</div>
|
||
<div style={{ color: "var(--color-text-2)", lineHeight: 1.3 }}>
|
||
{match.segment_text.length > 80 ? match.segment_text.slice(0, 80) + '…' : match.segment_text}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* LLM Config Settings Modal */}
|
||
<LLMConfigModal
|
||
isOpen={showSettingsModal}
|
||
onClose={() => setShowSettingsModal(false)}
|
||
/>
|
||
|
||
{/* Summary Type Selection Modal */}
|
||
{showSummaryModal && (
|
||
<div
|
||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowSummaryModal(false); }}
|
||
>
|
||
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||
<div
|
||
style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
maxWidth: '360px',
|
||
backgroundColor: 'var(--color-panel)',
|
||
border: '1px solid var(--color-divider)',
|
||
borderRadius: '10px',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||
overflow: 'hidden',
|
||
zIndex: 1,
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div style={{
|
||
padding: '14px 16px',
|
||
borderBottom: '1px solid var(--color-divider)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}>
|
||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>Generate Summary</span>
|
||
<button
|
||
onClick={() => setShowSummaryModal(false)}
|
||
style={{ padding: '2px 6px', border: 'none', backgroundColor: 'transparent', color: 'var(--color-text-2)', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-2)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||
Summary Type
|
||
</label>
|
||
<select
|
||
value={summaryType}
|
||
onChange={(e) => setSummaryType(e.target.value)}
|
||
style={{ width: '100%', padding: '7px 10px', border: '1px solid var(--color-divider)', borderRadius: '6px', backgroundColor: 'var(--color-muted)', color: 'var(--color-text)', fontSize: '13px', outline: 'none' }}
|
||
>
|
||
{SUMMARY_TYPES.map((t) => (
|
||
<option key={t.value} value={t.value}>{t.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{
|
||
padding: '8px 10px',
|
||
borderRadius: '6px',
|
||
fontSize: '12px',
|
||
backgroundColor: llmConfig.model ? 'var(--color-muted)' : '#fef2f2',
|
||
color: llmConfig.model ? 'var(--color-text-2)' : '#dc2626',
|
||
border: '1px solid var(--color-divider)',
|
||
}}>
|
||
{llmConfig.model
|
||
? <>✓ {llmConfig.provider} · {llmConfig.model}</>
|
||
: <>⚠ No model configured — open Settings first</>
|
||
}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerateSummary}
|
||
disabled={isGeneratingSummary || !llmConfig.model}
|
||
style={{
|
||
padding: '9px',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
backgroundColor: isGeneratingSummary || !llmConfig.model ? '#9ca3af' : '#7c3aed',
|
||
color: '#fff',
|
||
fontSize: '13px',
|
||
fontWeight: 600,
|
||
cursor: isGeneratingSummary || !llmConfig.model ? 'not-allowed' : 'pointer',
|
||
opacity: isGeneratingSummary || !llmConfig.model ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{isGeneratingSummary ? 'Generating…' : 'Generate'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Spinner animation style */}
|
||
<style>{`
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
};
|