feat(cis): add Supabase integration, canvas event logger, and sessions tab (Phase 2)
- Connect transcriptionStore to Supabase (start/stop session, save segments) - Add CanvasEventLogger for silent TLDraw activity tracking - Add Sessions tab to CCTranscriptionPanel with past sessions list - Auto-detect timetable context on panel mount - Flush canvas events to API every 5 seconds during recording
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
text: string;
|
||||
@@ -7,40 +8,142 @@ export interface TranscriptionSegment {
|
||||
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 TimetablePeriod {
|
||||
period_id: string | null;
|
||||
event_type: string | null;
|
||||
event_label: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
startSession: () => void;
|
||||
stopSession: () => void;
|
||||
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => void;
|
||||
|
||||
// Actions
|
||||
startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
|
||||
stopSession: () => Promise<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;
|
||||
}
|
||||
|
||||
export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
isRecording: false,
|
||||
isConnecting: false,
|
||||
activeSession: null,
|
||||
completedSegments: [],
|
||||
currentSegment: null,
|
||||
pendingCanvasEvents: [],
|
||||
timetableContext: null,
|
||||
wordCount: 0,
|
||||
elapsedSeconds: 0,
|
||||
|
||||
startSession: () => {
|
||||
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0 });
|
||||
setTimetableContext: (context) => {
|
||||
set({ timetableContext: context });
|
||||
},
|
||||
|
||||
stopSession: () => {
|
||||
set({ isRecording: false, isConnecting: false });
|
||||
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();
|
||||
if (!user.data.user) {
|
||||
console.error('No authenticated user');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
user_id: user.data.user.id,
|
||||
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, 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);
|
||||
}
|
||||
},
|
||||
|
||||
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
|
||||
const { completedSegments, currentSegment } = get();
|
||||
stopSession: async () => {
|
||||
const { activeSession, completedSegments } = get();
|
||||
|
||||
if (activeSession) {
|
||||
try {
|
||||
await supabase
|
||||
.from('transcription_sessions')
|
||||
.update({
|
||||
ended_at: new Date().toISOString(),
|
||||
word_count: get().wordCount,
|
||||
segment_count: completedSegments.length,
|
||||
})
|
||||
.eq('id', activeSession.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to end session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
isRecording: false,
|
||||
isConnecting: false,
|
||||
activeSession: null,
|
||||
});
|
||||
},
|
||||
|
||||
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
|
||||
const newCompleted = [...completedSegments];
|
||||
@@ -51,7 +154,29 @@ 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 {
|
||||
const sequenceIndex = newCompleted.length - 1;
|
||||
await supabase.from('transcription_segments').insert({
|
||||
session_id: activeSession.id,
|
||||
sequence_index: sequenceIndex,
|
||||
text: 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
|
||||
set({ currentSegment: { text, isFinal: false, ...metadata } });
|
||||
@@ -59,10 +184,78 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
},
|
||||
|
||||
resetSession: () => {
|
||||
set({ isRecording: false, isConnecting: false, completedSegments: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0 });
|
||||
set({
|
||||
isRecording: false,
|
||||
isConnecting: false,
|
||||
completedSegments: [],
|
||||
currentSegment: null,
|
||||
wordCount: 0,
|
||||
elapsedSeconds: 0,
|
||||
activeSession: null,
|
||||
pendingCanvasEvents: [],
|
||||
timetableContext: null,
|
||||
});
|
||||
},
|
||||
|
||||
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 supabase.from('canvas_events').insert({
|
||||
session_id: activeSession?.id || null,
|
||||
user_id: (await supabase.auth.getUser()).data.user?.id || '',
|
||||
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 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 [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user