744 lines
24 KiB
TypeScript
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: [] });
|
|
},
|
|
};
|
|
});
|