feat(cis): add LLM config modal, summary generation, keywords tab, and export button (Phase 3)

- LLMConfigModal: provider dropdown, model field, API key input (localStorage only)
- Summary generation: modal with 5 summary types, calls API, displays result
- Keywords tab: add/delete watches, real-time detection, event logging
- Export button: SRT/TXT/JSON download via API endpoint
- All Phase 3 frontend tasks complete
This commit is contained in:
2026-05-20 22:57:06 +00:00
parent 6bbed42f55
commit cb8c2dab74
3 changed files with 639 additions and 43 deletions
+182 -35
View File
@@ -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<void>;
stopSession: () => Promise<void>;
@@ -61,6 +107,19 @@ interface TranscriptionState {
flushCanvasEvents: () => Promise<void>;
loadSessions: () => Promise<TranscriptionSession[]>;
setTimetableContext: (context: TimetablePeriod | null) => void;
// LLM config actions
setLLMConfig: (config: Partial<LLMConfig>) => 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<void>;
setExportError: (error: string | null) => void;
}
export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
@@ -74,13 +133,25 @@ export const useTranscriptionStore = create<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((set, get) => ({
stopSession: async () => {
const { activeSession, completedSegments } = get();
if (activeSession) {
try {
await supabase
@@ -133,9 +204,9 @@ export const useTranscriptionStore = create<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<TranscriptionState>((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<LLMConfig>) => {
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 });
},
}));