fix(panels): resolve sidebar refresh bugs — getUser→getSession, files double-call, hardcoded IP
- transcriptionStore: replace all supabase.auth.getUser() with getSession() so session restoration on page refresh does not race against GoTrue network validation - CCFilesPanel: remove selectedCabinet from loadCabinets useCallback deps; use initialSelectionDone ref to prevent double-call on first mount - CCTranscriptionPanel: replace hardcoded LAN IP with VITE_API_URL env var Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -23,6 +23,12 @@ export interface TranscriptionSession {
|
||||
segment_count: number;
|
||||
}
|
||||
|
||||
export interface ServerSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface TimetablePeriod {
|
||||
period_id: string | null;
|
||||
event_type: string | null;
|
||||
@@ -35,6 +41,8 @@ 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';
|
||||
@@ -72,6 +80,8 @@ function loadLLMConfig(): LLMConfig {
|
||||
provider: 'openai',
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
whisperModel: 'large-v3',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,8 +100,9 @@ interface TranscriptionState {
|
||||
activeSession: TranscriptionSession | null;
|
||||
|
||||
// Live feed
|
||||
completedSegments: TranscriptionSegment[];
|
||||
currentSegment: TranscriptionSegment | null;
|
||||
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[];
|
||||
@@ -122,6 +133,7 @@ interface TranscriptionState {
|
||||
// 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;
|
||||
@@ -156,6 +168,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
isConnecting: false,
|
||||
activeSession: null,
|
||||
completedSegments: [],
|
||||
serverWindow: [],
|
||||
currentSegment: null,
|
||||
pendingCanvasEvents: [],
|
||||
timetableContext: null,
|
||||
@@ -187,14 +200,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
|
||||
// Create session in Supabase
|
||||
try {
|
||||
const user = await supabase.auth.getUser();
|
||||
if (!user.data.user) {
|
||||
const { data: sessionData_auth } = await supabase.auth.getSession();
|
||||
if (!sessionData_auth.session?.user) {
|
||||
console.error('No authenticated user');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
user_id: user.data.user.id,
|
||||
user_id: sessionData_auth.session.user.id,
|
||||
title: timetableTag?.event_label || 'Untitled Session',
|
||||
canvas_type: 'teaching-canvas',
|
||||
timetable_period_id: timetableTag?.period_id || null,
|
||||
@@ -221,7 +234,32 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
},
|
||||
|
||||
stopSession: async () => {
|
||||
const { activeSession, completedSegments } = get();
|
||||
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) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
session_id: activeSession.id,
|
||||
sequence_index: idx,
|
||||
text: currentSegment.text,
|
||||
start_seconds: currentSegment.start,
|
||||
end_seconds: currentSegment.end,
|
||||
is_final: true,
|
||||
}).then(({ error }) => { if (error) console.error('Failed to save live segment on stop:', error); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalWordCount = newCompleted.reduce(
|
||||
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||
0
|
||||
);
|
||||
|
||||
if (activeSession) {
|
||||
try {
|
||||
@@ -229,8 +267,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
.from('transcription_sessions')
|
||||
.update({
|
||||
ended_at: new Date().toISOString(),
|
||||
word_count: get().wordCount,
|
||||
segment_count: completedSegments.length,
|
||||
word_count: finalWordCount,
|
||||
segment_count: newCompleted.length,
|
||||
})
|
||||
.eq('id', activeSession.id);
|
||||
} catch (error) {
|
||||
@@ -242,35 +280,121 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
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) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
session_id: activeSession.id,
|
||||
sequence_index: idx,
|
||||
text: seg.text,
|
||||
start_seconds: seg.start,
|
||||
end_seconds: seg.end,
|
||||
is_final: true,
|
||||
}).then(({ error }) => { if (error) console.error('Failed to save segment:', error); });
|
||||
}
|
||||
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, wordCount } = get();
|
||||
const { completedSegments, currentSegment, activeSession } = get();
|
||||
|
||||
if (isFinal) {
|
||||
// Final segment — append the finalized text directly (not currentSegment, which
|
||||
// may lag behind or duplicate when WhisperLive re-sends the full segments array).
|
||||
const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
|
||||
// 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,
|
||||
});
|
||||
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
|
||||
|
||||
// Save to Supabase if session is active
|
||||
if (activeSession) {
|
||||
if (isNew && activeSession) {
|
||||
try {
|
||||
const sequenceIndex = newCompleted.length - 1;
|
||||
await supabase.from('transcription_segments').insert({
|
||||
session_id: activeSession.id,
|
||||
sequence_index: sequenceIndex,
|
||||
text: text,
|
||||
sequence_index: newCompleted.length - 1,
|
||||
text,
|
||||
start_seconds: metadata.start,
|
||||
end_seconds: metadata.end,
|
||||
is_final: true,
|
||||
@@ -280,7 +404,26 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In-progress segment
|
||||
// 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) {
|
||||
supabase.from('transcription_segments').insert({
|
||||
session_id: activeSession.id,
|
||||
sequence_index: autoCompleted.length - 1,
|
||||
text: currentSegment.text,
|
||||
start_seconds: currentSegment.start,
|
||||
end_seconds: currentSegment.end,
|
||||
is_final: true,
|
||||
}).then(({ error }) => { if (error) console.error('Failed to save auto-committed segment:', error); });
|
||||
}
|
||||
}
|
||||
set({ currentSegment: { text, isFinal: false, ...metadata } });
|
||||
}
|
||||
},
|
||||
@@ -290,6 +433,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
isRecording: false,
|
||||
isConnecting: false,
|
||||
completedSegments: [],
|
||||
serverWindow: [],
|
||||
currentSegment: null,
|
||||
wordCount: 0,
|
||||
elapsedSeconds: 0,
|
||||
@@ -321,7 +465,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
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 || '',
|
||||
user_id: (await supabase.auth.getSession()).data.session?.user?.id || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
session_elapsed_seconds: event.sessionElapsedSeconds || null,
|
||||
event_type: event.eventType,
|
||||
@@ -340,13 +484,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
|
||||
loadSessions: async (): Promise<TranscriptionSession[]> => {
|
||||
try {
|
||||
const user = await supabase.auth.getUser();
|
||||
if (!user.data.user) return [];
|
||||
const { data: sessionData_auth } = await supabase.auth.getSession();
|
||||
if (!sessionData_auth.session?.user) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transcription_sessions')
|
||||
.select('*')
|
||||
.eq('user_id', user.data.user.id)
|
||||
.eq('user_id', sessionData_auth.session.user.id)
|
||||
.order('started_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
@@ -457,9 +601,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
addKeywordWatch: async (keyword: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
const user = await supabase.auth.getUser();
|
||||
if (!user.data.user) return;
|
||||
if (!session?.access_token || !session.user) return;
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
|
||||
method: 'POST',
|
||||
@@ -468,7 +610,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: user.data.user.id,
|
||||
user_id: session.user.id,
|
||||
keyword: keyword.trim(),
|
||||
match_type: 'contains',
|
||||
action: 'alert',
|
||||
|
||||
Reference in New Issue
Block a user