diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index 72cb6d6..7b608a3 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -31,26 +31,72 @@ export interface TimetablePeriod { end_time: string | null; } +export interface LLMConfig { + provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; + model: string; + apiKey: string; +} + +export type ExportFormat = 'srt' | 'txt' | 'json'; + +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: '', + }; +} + +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[]; currentSegment: TranscriptionSegment | null; - + // 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; + // Actions startSession: (timetableTag?: TimetablePeriod) => Promise; stopSession: () => Promise; @@ -61,6 +107,19 @@ interface TranscriptionState { 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; } export const useTranscriptionStore = create((set, get) => ({ @@ -74,13 +133,25 @@ export const useTranscriptionStore = create((set, get) => ({ 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, + setTimetableContext: (context) => { set({ timetableContext: context }); }, startSession: async (timetableTag?: TimetablePeriod) => { set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); - + // Create session in Supabase try { const user = await supabase.auth.getUser(); @@ -88,7 +159,7 @@ export const useTranscriptionStore = create((set, get) => ({ console.error('No authenticated user'); return; } - + const sessionData = { user_id: user.data.user.id, title: timetableTag?.event_label || 'Untitled Session', @@ -98,18 +169,18 @@ export const useTranscriptionStore = create((set, get) => ({ timetable_event_label: timetableTag?.event_label || null, auto_tagged: !!timetableTag, }; - + const { data, error } = await supabase .from('transcription_sessions') .insert(sessionData) .select() .single(); - + if (error) { console.error('Failed to create session:', error); return; } - + set({ activeSession: data }); } catch (error) { console.error('Error starting session:', error); @@ -118,7 +189,7 @@ export const useTranscriptionStore = create((set, get) => ({ stopSession: async () => { const { activeSession, completedSegments } = get(); - + if (activeSession) { try { await supabase @@ -133,9 +204,9 @@ export const useTranscriptionStore = create((set, get) => ({ console.error('Failed to end session:', error); } } - - set({ - isRecording: false, + + set({ + isRecording: false, isConnecting: false, activeSession: null, }); @@ -143,9 +214,9 @@ export const useTranscriptionStore = create((set, get) => ({ saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { const { completedSegments, currentSegment, activeSession, wordCount } = get(); - + if (isFinal) { - // Final segment — move current to completed, clear current + // Final segment - move current to completed, clear current const newCompleted = [...completedSegments]; if (currentSegment && currentSegment.text.trim()) { newCompleted.push({ ...currentSegment, isFinal: true }); @@ -154,13 +225,13 @@ export const useTranscriptionStore = create((set, get) => ({ (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, 0 ); - - set({ - completedSegments: newCompleted, - currentSegment: null, - wordCount: newWordCount + + set({ + completedSegments: newCompleted, + currentSegment: null, + wordCount: newWordCount, }); - + // Save to Supabase if session is active if (activeSession) { try { @@ -184,12 +255,12 @@ export const useTranscriptionStore = create((set, get) => ({ }, resetSession: () => { - set({ - isRecording: false, - isConnecting: false, - completedSegments: [], - currentSegment: null, - wordCount: 0, + set({ + isRecording: false, + isConnecting: false, + completedSegments: [], + currentSegment: null, + wordCount: 0, elapsedSeconds: 0, activeSession: null, pendingCanvasEvents: [], @@ -209,11 +280,11 @@ export const useTranscriptionStore = create((set, get) => ({ flushCanvasEvents: async () => { const { pendingCanvasEvents, activeSession } = get(); - + if (pendingCanvasEvents.length === 0) return; - + const eventsToFlush = [...pendingCanvasEvents]; - + try { for (const event of eventsToFlush) { await supabase.from('canvas_events').insert({ @@ -228,7 +299,7 @@ export const useTranscriptionStore = create((set, get) => ({ tldraw_shape_ids: event.shapeIds || null, }); } - + set({ pendingCanvasEvents: [] }); } catch (error) { console.error('Failed to flush canvas events:', error); @@ -239,23 +310,99 @@ export const useTranscriptionStore = create((set, get) => ({ try { const user = await supabase.auth.getUser(); if (!user.data.user) return []; - + const { data, error } = await supabase .from('transcription_sessions') .select('*') .eq('user_id', user.data.user.id) .order('started_at', { ascending: false }) .limit(50); - + if (error) { console.error('Failed to load sessions:', error); return []; } - + 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 || 'https://api.classroomcopilot.ai'; + 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[^;=\n]*=([(["]).*?(\2)|[^;\n]*)/); + 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 }); + }, })); diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index 18f72ac..5b664f1 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useRef, useState } from "react"; -import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Add as AddIcon } from "@mui/icons-material"; +import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod } 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 => { @@ -18,6 +19,14 @@ const formatDateTime = (isoString: string): string => { type TabType = "live" | "sessions"; +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, @@ -36,6 +45,13 @@ export const CCTranscriptionPanel: React.FC = () => { flushCanvasEvents, loadSessions, setTimetableContext, + llmConfig, + summaryText, + isGeneratingSummary, + summaryError, + setSummaryText, + setIsGeneratingSummary, + setSummaryError, } = useTranscriptionStore(); const [activeTab, setActiveTab] = useState("live"); @@ -45,6 +61,11 @@ export const CCTranscriptionPanel: React.FC = () => { 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'); + // Load sessions on mount useEffect(() => { loadSessions().then(setSessions); @@ -103,8 +124,6 @@ export const CCTranscriptionPanel: React.FC = () => { // Initialize canvas event logger if session was created const state = useTranscriptionStore.getState(); if (state.activeSession) { - // TODO: Get editor instance from TLDraw context - // For now, just log that canvas logging would start console.log('[CCTranscriptionPanel] Canvas event logging would start for session', state.activeSession.id); } } catch (error) { @@ -119,7 +138,6 @@ export const CCTranscriptionPanel: React.FC = () => { serviceRef.current = null; } - // Detach canvas event logger if (canvasLoggerRef.current) { canvasLoggerRef.current.detach(); canvasLoggerRef.current = null; @@ -142,6 +160,55 @@ export const CCTranscriptionPanel: React.FC = () => { setSessions(loaded); }; + // Generate summary handler + const handleGenerateSummary = async () => { + if (!activeSession) { + setSummaryError("No active session to generate summary for."); + return; + } + + const config = useTranscriptionStore.getState().llmConfig; + if (!config.apiKey) { + setSummaryError("Please configure your API key in Settings first."); + return; + } + + setIsGeneratingSummary(true); + setSummaryError(null); + setShowSummaryModal(false); + + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + summary_type: summaryType, + provider: config.provider, + model: config.model, + api_key: config.apiKey, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`); + } + + const data = await response.json(); + // The API returns the summary text in the response + const summary = data.summary || data.content || data.text || JSON.stringify(data); + setSummaryText(summary); + } 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 */} @@ -185,15 +252,35 @@ export const CCTranscriptionPanel: React.FC = () => { {/* Live Tab */} {activeTab === "live" && ( <> - {/* Session header */} + {/* Session header with settings button */}
{sessionName} -
- {wordCount} words - {formatTime(elapsedSeconds)} +
+ +
+ {wordCount} words + {formatTime(elapsedSeconds)} +
@@ -240,6 +327,159 @@ export const CCTranscriptionPanel: React.FC = () => {
+ {/* Generate Summary button (only when recording or has segments) */} + {(isRecording || completedSegments.length > 0) && ( + <> +
+
+ +
+ + {/* Export button */} + {activeSession && ( + <> +
+
+
+ Export Session +
+
+ {(['srt', 'txt', 'json'] as const).map((format) => ( + + ))} +
+
+ + )} + + )} + + {/* Summary result display */} + {summaryText && ( + <> +
+
+
+
+ + AI Summary +
+ +
+
+ {summaryText} +
+
+ + )} + + {/* Summary error display */} + {summaryError && ( + <> +
+
+
+ Error: {summaryError} +
+
+ + )} +
{/* Live feed */} @@ -359,6 +599,89 @@ export const CCTranscriptionPanel: React.FC = () => {
)} + + {/* LLM Config Settings Modal */} + setShowSettingsModal(false)} + /> + + {/* Summary Type Selection Modal */} + {showSummaryModal && ( +
+
setShowSummaryModal(false)} + /> +
+
+

+ Generate Summary +

+ +
+
+
+ + +
+ + {/* Config status indicator */} +
+ {llmConfig.apiKey ? ( + <>✓ Configured: {llmConfig.provider} ({llmConfig.model || 'default model'}) + ) : ( + <>⚠ No API key configured. Click the ⚙ icon to set up. + )} +
+ + +
+
+
+ )} + + {/* Spinner animation style */} +
); }; diff --git a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx new file mode 100644 index 0000000..23f0afc --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import Close from '@mui/icons-material/Close'; +import { useTranscriptionStore, LLMConfig } from '../../stores/transcriptionStore'; + +const PROVIDERS = [ + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'ollama', label: 'Ollama' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'google', label: 'Google' }, +] as const; + +const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { + const { llmConfig, setLLMConfig } = useTranscriptionStore(); + const [form, setForm] = useState(llmConfig); + const [saved, setSaved] = useState(false); + + useEffect(() => { + if (isOpen) { + setForm(useTranscriptionStore.getState().llmConfig); + setSaved(false); + } + }, [isOpen]); + + const handleSave = () => { + setLLMConfig(form); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal panel */} +
+ {/* Header */} +
+

+ LLM Provider Settings +

+ +
+ + {/* Content */} +
+ {/* Provider dropdown */} +
+ + +
+ + {/* Model name */} +
+ + setForm({ ...form, model: e.target.value })} + placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* API Key */} +
+ + setForm({ ...form, apiKey: e.target.value })} + placeholder="sk-..." + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Note */} +

+ API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server. +

+ + {/* Save button */} + +
+
+
+ ); +}; + +export default LLMConfigModal;