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:
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user