app/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx
kcar 5284d30f84 fix(panels): resolve sidebar refresh bugs — getUser→getSession, files double-call, hardcoded IP
- 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>
2026-05-25 13:41:25 +00:00

1079 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 35 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
segment: `Summarise the following classroom transcript in 23 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>
);
};