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("live"); const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments'); const [sessions, setSessions] = useState([]); const [sessionName, setSessionName] = useState("Untitled Session"); const serviceRef = useRef(null); const timerRef = useRef | null>(null); const canvasLoggerRef = useRef(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 = { 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 = { '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 (
{/* Tab bar */}
{/* Live Tab */} {activeTab === "live" && ( <> {/* Session header with settings button */}
{sessionName}
{wordCount} words {formatTime(elapsedSeconds)}
{/* Timetable badge */} {timetableContext && timetableContext.event_label && (
📅 {timetableContext.event_label}
)}
{/* Record button */}
{/* Generate Summary button (only when recording or has segments) */} {(isRecording || completedSegments.length > 0) && ( <>
)} {/* Export button — available whenever there are completed segments */} {completedSegments.length > 0 && ( <>
Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
{(['srt', 'txt', 'json'] as const).map((format) => ( ))}
)} {/* Summary result display */} {summaryText && ( <>
AI Summary
{summaryText}
)} {/* Summary error display */} {summaryError && ( <>
Error: {summaryError}
)}
{/* Live feed */}
{/* Header row: title + view mode toggle */}
Live Feed
{(["segments", "transcript"] as const).map(mode => ( ))}
{(() => { const allFinal = completedSegments; // already merged from server on every message if (viewMode === "segments") { return ( <> {allFinal.map((seg, i) => (
{formatTime(Math.floor(seg.start))} → {formatTime(Math.floor(seg.end))}
{seg.text}
))} {currentSegment && (
{formatTime(Math.floor(currentSegment.start))} → …
{currentSegment.text || "Listening…"}
)} ); } // Transcript view — single joined box + separate live segment const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" "); return ( <> {(joinedText || !currentSegment) && (
{joinedText || No completed segments yet.}
)} {currentSegment && (
{formatTime(Math.floor(currentSegment.start))} → …
{currentSegment.text || "Listening…"}
)} ); })()} {!isRecording && completedSegments.length === 0 && !currentSegment && (
Press Start Recording to begin transcription
)}
)} {/* Sessions Tab */} {activeTab === "sessions" && ( <>
Past Sessions
{sessions.length === 0 ? (
No sessions yet
) : ( sessions.map((session) => (
{session.title || "Untitled Session"}
{formatDateTime(session.started_at)} {session.duration_seconds && ` · ${Math.floor(session.duration_seconds / 60)}m`} {session.segment_count && ` · ${session.segment_count} segments`}
{session.timetable_event_label && (
📅 {session.timetable_event_label}
)}
)) )}
)} {/* Keywords Tab */} {activeTab === "keywords" && ( <>
Keyword Watches
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", }} />
{keywordWatches.length === 0 ? (
No keyword watches yet
) : ( keywordWatches.filter((w) => w.is_active).map((watch) => (
{watch.keyword}
)) )}
{keywordMatches.length > 0 && ( <>
Matches this session
{[...keywordMatches].reverse().map((match, i) => (
"{match.keyword}" at {formatTime(Math.floor(match.elapsed_seconds))}
{match.segment_text.length > 80 ? match.segment_text.slice(0, 80) + '…' : match.segment_text}
))}
)} )} {/* LLM Config Settings Modal */} setShowSettingsModal(false)} /> {/* Summary Type Selection Modal */} {showSummaryModal && (
{ if (e.target === e.currentTarget) setShowSummaryModal(false); }} >
e.stopPropagation()} >
Generate Summary
{llmConfig.model ? <>✓ {llmConfig.provider} · {llmConfig.model} : <>⚠ No model configured — open Settings first }
)} {/* Spinner animation style */}
); };