Files
app/src/stores/transcriptionStore.ts
T
kcar fedbd903ff
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix: centralize app API URL fallbacks
2026-05-28 19:26:00 +01:00

744 lines
24 KiB
TypeScript

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<void>;
stopSession: () => Promise<void>;
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void;
tickElapsed: () => void;
addCanvasEvent: (event: any) => void;
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;
// Keyword actions
loadKeywordWatches: () => Promise<void>;
addKeywordWatch: (keyword: string) => Promise<void>;
deleteKeywordWatch: (watchId: string) => Promise<void>;
checkSegmentForKeywords: (text: string, elapsedSeconds: number) => Promise<void>;
clearKeywordMatches: () => void;
}
export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
// Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock.
const pgFetch = async <T = any>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
table: string,
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
): Promise<T | null> => {
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<string, string> = {
'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<T>;
};
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<TranscriptionSession>('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<TranscriptionSession[]> => {
try {
const { _userId: userId } = get();
if (!userId) return [];
const data = await pgFetch<TranscriptionSession[]>('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<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 || '/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: [] });
},
};
});