import { create } from 'zustand'; export interface TranscriptionSegment { text: string; isFinal: boolean; start: number; end: number; } export interface TranscriptionSession { id: string; user_id: string; title: string | null; started_at: string; ended_at: string | null; duration_seconds: number | null; timetable_period_id: string | null; timetable_event_type: string | null; timetable_event_label: string | null; auto_tagged: boolean; word_count: number; segment_count: number; } export interface ServerSegment { text: string; start: number; end: number; } export interface TimetablePeriod { period_id: string | null; event_type: string | null; event_label: string | null; start_time: string | null; end_time: string | null; } export interface LLMConfig { provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; model: string; apiKey: string; baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com whisperModel?: string; // faster-whisper model size sent to WhisperLive } export type ExportFormat = 'srt' | 'txt' | 'json'; export interface KeywordWatch { id: string; user_id: string; keyword: string; match_type: string; action: string; is_active: boolean; created_at: string; } export interface KeywordMatch { keyword: string; watch_id: string | null; segment_text: string; elapsed_seconds: number; matched_at: string; } const LLM_CONFIG_STORAGE_KEY = 'cc_llm_config'; function loadLLMConfig(): LLMConfig { try { const stored = localStorage.getItem(LLM_CONFIG_STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (e) { console.error('Failed to load LLM config from localStorage:', e); } return { provider: 'openai', model: '', apiKey: '', baseUrl: '', whisperModel: 'large-v3', }; } function saveLLMConfig(config: LLMConfig): void { try { localStorage.setItem(LLM_CONFIG_STORAGE_KEY, JSON.stringify(config)); } catch (e) { console.error('Failed to save LLM Config to localStorage:', e); } } interface TranscriptionState { // Session state isRecording: boolean; isConnecting: boolean; activeSession: TranscriptionSession | null; // Live feed completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived) serverWindow: ServerSegment[]; // the current server-provided segment window (last N) currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined // Canvas event buffer (flushed to API every 5s) pendingCanvasEvents: any[]; // Timetable context timetableContext: TimetablePeriod | null; // UI state wordCount: number; elapsedSeconds: number; // LLM config (stored in localStorage only) llmConfig: LLMConfig; // Summary state summaryText: string | null; isGeneratingSummary: boolean; summaryError: string | null; // Export state isExporting: boolean; exportError: string | null; // Keyword state keywordWatches: KeywordWatch[]; keywordMatches: KeywordMatch[]; // Auth (set by panel via setAuthInfo after SIGNED_IN) _accessToken: string | null; _userId: string | null; setAuthInfo: (token: string | null, userId: string | null) => void; // Actions startSession: (timetableTag?: TimetablePeriod) => Promise; stopSession: () => Promise; updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise; resetSession: () => void; tickElapsed: () => void; addCanvasEvent: (event: any) => void; flushCanvasEvents: () => Promise; loadSessions: () => Promise; setTimetableContext: (context: TimetablePeriod | null) => void; // LLM config actions setLLMConfig: (config: Partial) => void; getLLMConfig: () => LLMConfig; // Summary actions setSummaryText: (text: string | null) => void; setIsGeneratingSummary: (generating: boolean) => void; setSummaryError: (error: string | null) => void; // Export actions exportSession: (sessionId: string, format: ExportFormat) => Promise; setExportError: (error: string | null) => void; // Keyword actions loadKeywordWatches: () => Promise; addKeywordWatch: (keyword: string) => Promise; deleteKeywordWatch: (watchId: string) => Promise; checkSegmentForKeywords: (text: string, elapsedSeconds: number) => Promise; clearKeywordMatches: () => void; } export const useTranscriptionStore = create((set, get) => { // Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock. const pgFetch = async ( method: 'GET' | 'POST' | 'PATCH' | 'DELETE', table: string, options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} ): Promise => { const token = get()._accessToken; if (!token) throw new Error('pgFetch: no access token'); const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; const headers: Record = { 'Authorization': `Bearer ${token}`, 'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, 'Content-Type': 'application/json', }; if (options.prefer) headers['Prefer'] = options.prefer; if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json'; const res = await fetch(url, { method, headers, ...(options.body ? { body: JSON.stringify(options.body) } : {}), }); if (!res.ok) { const err = await res.text(); throw new Error(`PostgREST ${res.status}: ${err}`); } if (res.status === 204) return null; return res.json() as Promise; }; return { isRecording: false, isConnecting: false, activeSession: null, _accessToken: null, _userId: null, completedSegments: [], serverWindow: [], currentSegment: null, pendingCanvasEvents: [], timetableContext: null, wordCount: 0, elapsedSeconds: 0, // LLM config initialized from localStorage llmConfig: loadLLMConfig(), // Summary state summaryText: null, isGeneratingSummary: false, summaryError: null, // Export state isExporting: false, exportError: null, // Keyword state keywordWatches: [], keywordMatches: [], setTimetableContext: (context) => { set({ timetableContext: context }); }, setAuthInfo: (token: string | null, userId: string | null) => { set({ _accessToken: token, _userId: userId }); }, startSession: async (timetableTag?: TimetablePeriod) => { set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); try { const { _userId: userId } = get(); if (!userId) { console.error('No authenticated user'); return; } const sessionData = { user_id: userId, title: timetableTag?.event_label || 'Untitled Session', canvas_type: 'teaching-canvas', timetable_period_id: timetableTag?.period_id || null, timetable_event_type: timetableTag?.event_type || null, timetable_event_label: timetableTag?.event_label || null, auto_tagged: !!timetableTag, }; const data = await pgFetch('POST', 'transcription_sessions', { body: sessionData, prefer: 'return=representation', single: true, }); if (!data) { console.error('Failed to create session: no data returned'); return; } set({ activeSession: data }); } catch (error) { console.error('Error starting session:', error); } }, stopSession: async () => { const { activeSession, currentSegment, completedSegments } = get(); // The live segment (currentSegment) was never added to completedSegments — flush it now. let newCompleted = [...completedSegments]; if (currentSegment && currentSegment.text.trim()) { const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5); if (!alreadyIn) { const idx = newCompleted.length; newCompleted.push({ ...currentSegment, isFinal: true }); if (activeSession) { pgFetch('POST', 'transcription_segments', { body: { session_id: activeSession.id, sequence_index: idx, text: currentSegment.text, start_seconds: currentSegment.start, end_seconds: currentSegment.end, is_final: true, }, }).catch(err => console.error('Failed to save live segment on stop:', err)); } } } const finalWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); if (activeSession) { try { await pgFetch('PATCH', 'transcription_sessions', { query: `id=eq.${activeSession.id}`, body: { ended_at: new Date().toISOString(), word_count: finalWordCount, segment_count: newCompleted.length, }, }); } catch (error) { console.error('Failed to end session:', error); } } set({ isRecording: false, isConnecting: false, activeSession: null, completedSegments: newCompleted, serverWindow: [], currentSegment: null, wordCount: finalWordCount, }); }, updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => { const { completedSegments, activeSession } = get(); if (segments.length === 0) return; // The server marks every finalized segment with completed=true and the live // one with completed=false. Rather than relying on window-scroll detection // (which can miss segments when the server creates several at once), we // directly merge every completed segment from this message into the store. // This guarantees no gaps: any segment the server says is complete is captured // immediately, regardless of how many were created since the last message. const serverCompleted = isLastLive ? segments.slice(0, -1) : segments; let newCompleted = [...completedSegments]; const toSave: Array<{ seg: ServerSegment; idx: number }> = []; for (const seg of serverCompleted) { if (!seg.text.trim()) continue; const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5); if (existingIdx >= 0) { // Server refined an existing segment — update text and end time in place. newCompleted[existingIdx] = { ...newCompleted[existingIdx], text: seg.text, end: seg.end, }; } else { const newIdx = newCompleted.length; newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end }); toSave.push({ seg, idx: newIdx }); } } // Keep sorted by start time so display order is always correct. newCompleted.sort((a, b) => a.start - b.start); // Persist and keyword-check only truly new segments. if (toSave.length > 0) { const elapsed = get().elapsedSeconds; for (const { seg, idx } of toSave) { if (activeSession) { pgFetch('POST', 'transcription_segments', { body: { session_id: activeSession.id, sequence_index: idx, text: seg.text, start_seconds: seg.start, end_seconds: seg.end, is_final: true, }, }).catch(err => console.error('Failed to save segment:', err)); } get().checkSegmentForKeywords(seg.text, elapsed); } } const lastSeg = segments[segments.length - 1]; const newCurrentSegment: TranscriptionSegment | null = isLastLive ? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end } : null; const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); set({ serverWindow: segments, completedSegments: newCompleted, currentSegment: newCurrentSegment, wordCount: newWordCount, }); }, saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { const { completedSegments, currentSegment, activeSession } = get(); if (isFinal) { // Deduplicate by start time: if a segment with this start already exists, update it // rather than appending. This prevents doubles when the stability timer fires and // the segment later appears in the server's finalized list with a slightly extended end. const existingIdx = completedSegments.findIndex( (s) => Math.abs(s.start - metadata.start) < 0.5 ); let newCompleted: TranscriptionSegment[]; let isNew: boolean; if (existingIdx >= 0) { newCompleted = completedSegments.map((s, i) => i === existingIdx ? { text, isFinal: true, ...metadata } : s ); isNew = false; } else { newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }]; isNew = true; } const newWordCount = newCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount }); if (isNew && activeSession) { try { await pgFetch('POST', 'transcription_segments', { body: { session_id: activeSession.id, sequence_index: newCompleted.length - 1, text, start_seconds: metadata.start, end_seconds: metadata.end, is_final: true, }, }); } catch (error) { console.error('Failed to save segment:', error); } } } else { // In-progress segment. If the start time jumped to a new position, the previous // live segment is done — auto-commit it before switching. if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) { const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }]; const autoWordCount = autoCompleted.reduce( (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); set({ completedSegments: autoCompleted, wordCount: autoWordCount }); if (activeSession) { pgFetch('POST', 'transcription_segments', { body: { session_id: activeSession.id, sequence_index: autoCompleted.length - 1, text: currentSegment.text, start_seconds: currentSegment.start, end_seconds: currentSegment.end, is_final: true, }, }).catch(err => console.error('Failed to save auto-committed segment:', err)); } } set({ currentSegment: { text, isFinal: false, ...metadata } }); } }, resetSession: () => { set({ isRecording: false, isConnecting: false, completedSegments: [], serverWindow: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0, activeSession: null, pendingCanvasEvents: [], timetableContext: null, keywordMatches: [], }); }, tickElapsed: () => { set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 })); }, addCanvasEvent: (event) => { set((state) => ({ pendingCanvasEvents: [...state.pendingCanvasEvents, event], })); }, flushCanvasEvents: async () => { const { pendingCanvasEvents, activeSession } = get(); if (pendingCanvasEvents.length === 0) return; const eventsToFlush = [...pendingCanvasEvents]; try { for (const event of eventsToFlush) { await pgFetch('POST', 'canvas_events', { body: { session_id: activeSession?.id || null, user_id: get()._userId || '', timestamp: new Date().toISOString(), session_elapsed_seconds: event.sessionElapsedSeconds || null, event_type: event.eventType, event_payload: event.payload || {}, canvas_snapshot_url: event.snapshotUrl || null, tldraw_page_id: event.pageId || null, tldraw_shape_ids: event.shapeIds || null, }, }); } set({ pendingCanvasEvents: [] }); } catch (error) { console.error('Failed to flush canvas events:', error); } }, loadSessions: async (): Promise => { try { const { _userId: userId } = get(); if (!userId) return []; const data = await pgFetch('GET', 'transcription_sessions', { query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`, }); return data || []; } catch (error) { console.error('Error loading sessions:', error); return []; } }, // LLM config actions - persist to localStorage only setLLMConfig: (partialConfig: Partial) => { const current = get().llmConfig; const updated = { ...current, ...partialConfig }; saveLLMConfig(updated); set({ llmConfig: updated }); }, getLLMConfig: (): LLMConfig => { return get().llmConfig; }, // Summary actions setSummaryText: (text: string | null) => { set({ summaryText: text }); }, setIsGeneratingSummary: (generating: boolean) => { set({ isGeneratingSummary: generating }); }, setSummaryError: (error: string | null) => { set({ summaryError: error }); }, // Export actions exportSession: async (sessionId: string, format: ExportFormat) => { set({ isExporting: true, exportError: null }); try { const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api'; const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ format }), }); if (!response.ok) { const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`); } // Get filename from Content-Disposition header or use default const disposition = response.headers.get('Content-Disposition'); let filename = `transcription-export.${format}`; if (disposition) { const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/); if (match && match[1]) { filename = match[1].replace(/["'\\]/g, ''); } } // Trigger browser download const blob = await response.blob(); 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); } catch (error) { console.error('Failed to export session:', error); set({ exportError: error instanceof Error ? error.message : 'Failed to export session' }); } finally { set({ isExporting: false }); } }, setExportError: (error: string | null) => { set({ exportError: error }); }, loadKeywordWatches: async () => { try { const { _accessToken: token } = get(); if (!token) return; const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api'; const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { headers: { 'Authorization': `Bearer ${token}` }, }); if (!response.ok) return; const watches = await response.json(); set({ keywordWatches: watches }); } catch (error) { console.error('Failed to load keyword watches:', error); } }, addKeywordWatch: async (keyword: string) => { try { const { _accessToken: token, _userId: userId } = get(); if (!token || !userId) return; const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api'; const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ user_id: userId, keyword: keyword.trim(), match_type: 'contains', action: 'alert', }), }); if (!response.ok) return; const newWatch = await response.json(); set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); } catch (error) { console.error('Failed to add keyword watch:', error); } }, deleteKeywordWatch: async (watchId: string) => { try { const { _accessToken: token } = get(); if (!token) return; const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api'; await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` }, }); set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); } catch (error) { console.error('Failed to delete keyword watch:', error); } }, checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { const { keywordWatches, activeSession } = get(); if (keywordWatches.length === 0) return; const lowerText = text.toLowerCase(); const matches: KeywordMatch[] = []; for (const watch of keywordWatches) { if (!watch.is_active) continue; const lowerKeyword = watch.keyword.toLowerCase(); const matched = watch.match_type === 'exact' ? lowerText === lowerKeyword : watch.match_type === 'starts_with' ? lowerText.startsWith(lowerKeyword) : lowerText.includes(lowerKeyword); if (matched) { matches.push({ keyword: watch.keyword, watch_id: watch.id, segment_text: text, elapsed_seconds: elapsedSeconds, matched_at: new Date().toISOString(), }); if (activeSession) { try { const { _accessToken: kwToken } = get(); const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api'; await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}), }, body: JSON.stringify({ session_id: activeSession.id, keyword_watch_id: watch.id, keyword_text: watch.keyword, matched_in_text: text, session_elapsed_seconds: elapsedSeconds, }), }); } catch (error) { console.error('Failed to log keyword event:', error); } } } } if (matches.length > 0) { set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); } }, clearKeywordMatches: () => { set({ keywordMatches: [] }); }, }; });