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 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-25 13:41:25 +00:00
parent fb1795fd2b
commit 5284d30f84
3 changed files with 502 additions and 163 deletions

View File

@ -23,6 +23,12 @@ export interface TranscriptionSession {
segment_count: number; segment_count: number;
} }
export interface ServerSegment {
text: string;
start: number;
end: number;
}
export interface TimetablePeriod { export interface TimetablePeriod {
period_id: string | null; period_id: string | null;
event_type: string | null; event_type: string | null;
@ -35,6 +41,8 @@ export interface LLMConfig {
provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google';
model: string; model: string;
apiKey: 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 type ExportFormat = 'srt' | 'txt' | 'json';
@ -72,6 +80,8 @@ function loadLLMConfig(): LLMConfig {
provider: 'openai', provider: 'openai',
model: '', model: '',
apiKey: '', apiKey: '',
baseUrl: '',
whisperModel: 'large-v3',
}; };
} }
@ -90,8 +100,9 @@ interface TranscriptionState {
activeSession: TranscriptionSession | null; activeSession: TranscriptionSession | null;
// Live feed // Live feed
completedSegments: TranscriptionSegment[]; completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived)
currentSegment: TranscriptionSegment | null; 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) // Canvas event buffer (flushed to API every 5s)
pendingCanvasEvents: any[]; pendingCanvasEvents: any[];
@ -122,6 +133,7 @@ interface TranscriptionState {
// Actions // Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>; startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>; stopSession: () => Promise<void>;
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void; resetSession: () => void;
tickElapsed: () => void; tickElapsed: () => void;
@ -156,6 +168,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
completedSegments: [], completedSegments: [],
serverWindow: [],
currentSegment: null, currentSegment: null,
pendingCanvasEvents: [], pendingCanvasEvents: [],
timetableContext: null, timetableContext: null,
@ -187,14 +200,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
// Create session in Supabase // Create session in Supabase
try { try {
const user = await supabase.auth.getUser(); const { data: sessionData_auth } = await supabase.auth.getSession();
if (!user.data.user) { if (!sessionData_auth.session?.user) {
console.error('No authenticated user'); console.error('No authenticated user');
return; return;
} }
const sessionData = { const sessionData = {
user_id: user.data.user.id, user_id: sessionData_auth.session.user.id,
title: timetableTag?.event_label || 'Untitled Session', title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas', canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null, timetable_period_id: timetableTag?.period_id || null,
@ -221,7 +234,32 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
}, },
stopSession: async () => { 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) { if (activeSession) {
try { try {
@ -229,8 +267,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
.from('transcription_sessions') .from('transcription_sessions')
.update({ .update({
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
word_count: get().wordCount, word_count: finalWordCount,
segment_count: completedSegments.length, segment_count: newCompleted.length,
}) })
.eq('id', activeSession.id); .eq('id', activeSession.id);
} catch (error) { } catch (error) {
@ -242,35 +280,121 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
completedSegments: newCompleted,
serverWindow: [],
currentSegment: null,
wordCount: finalWordCount,
}); });
}, },
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => {
const { completedSegments, currentSegment, activeSession, wordCount } = get(); 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;
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 }];
const newWordCount = newCompleted.reduce( const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0 0
); );
set({ set({
serverWindow: segments,
completedSegments: newCompleted, completedSegments: newCompleted,
currentSegment: null, currentSegment: newCurrentSegment,
wordCount: newWordCount, wordCount: newWordCount,
}); });
},
// Save to Supabase if session is active saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
if (activeSession) { 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 { try {
const sequenceIndex = newCompleted.length - 1;
await supabase.from('transcription_segments').insert({ await supabase.from('transcription_segments').insert({
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: sequenceIndex, sequence_index: newCompleted.length - 1,
text: text, text,
start_seconds: metadata.start, start_seconds: metadata.start,
end_seconds: metadata.end, end_seconds: metadata.end,
is_final: true, is_final: true,
@ -280,7 +404,26 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
} }
} }
} else { } 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 } }); set({ currentSegment: { text, isFinal: false, ...metadata } });
} }
}, },
@ -290,6 +433,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
completedSegments: [], completedSegments: [],
serverWindow: [],
currentSegment: null, currentSegment: null,
wordCount: 0, wordCount: 0,
elapsedSeconds: 0, elapsedSeconds: 0,
@ -321,7 +465,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
for (const event of eventsToFlush) { for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({ await supabase.from('canvas_events').insert({
session_id: activeSession?.id || null, 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(), timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null, session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType, event_type: event.eventType,
@ -340,13 +484,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
loadSessions: async (): Promise<TranscriptionSession[]> => { loadSessions: async (): Promise<TranscriptionSession[]> => {
try { try {
const user = await supabase.auth.getUser(); const { data: sessionData_auth } = await supabase.auth.getSession();
if (!user.data.user) return []; if (!sessionData_auth.session?.user) return [];
const { data, error } = await supabase const { data, error } = await supabase
.from('transcription_sessions') .from('transcription_sessions')
.select('*') .select('*')
.eq('user_id', user.data.user.id) .eq('user_id', sessionData_auth.session.user.id)
.order('started_at', { ascending: false }) .order('started_at', { ascending: false })
.limit(50); .limit(50);
@ -457,9 +601,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
addKeywordWatch: async (keyword: string) => { addKeywordWatch: async (keyword: string) => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return; if (!session?.access_token || !session.user) return;
const user = await supabase.auth.getUser();
if (!user.data.user) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST', method: 'POST',
@ -468,7 +610,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
'Authorization': `Bearer ${session.access_token}`, 'Authorization': `Bearer ${session.access_token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
user_id: user.data.user.id, user_id: session.user.id,
keyword: keyword.trim(), keyword: keyword.trim(),
match_type: 'contains', match_type: 'contains',
action: 'alert', action: 'alert',

View File

@ -115,6 +115,7 @@ export const CCFilesPanel: React.FC = () => {
const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([ const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([
{ id: null, name: 'Root' } { id: null, name: 'Root' }
]); ]);
const initialSelectionDone = useRef(false);
// Directory upload state // Directory upload state
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]); const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
@ -158,13 +159,16 @@ export const CCFilesPanel: React.FC = () => {
const data = await apiFetch('/database/cabinets'); const data = await apiFetch('/database/cabinets');
const all = [...(data.owned || []), ...(data.shared || [])]; const all = [...(data.owned || []), ...(data.shared || [])];
setCabinets(all); setCabinets(all);
if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id); if (all.length && !initialSelectionDone.current) {
initialSelectionDone.current = true;
setSelectedCabinet(all[0].id);
}
} catch (error) { } catch (error) {
console.error('Failed to load cabinets:', error); console.error('Failed to load cabinets:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedCabinet, apiFetch]); }, [apiFetch]);
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => { const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
if (!cabinetId) return; if (!cabinetId) return;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material"; import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material";
import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch } from "../../../../../stores/transcriptionStore"; import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore";
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger"; import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger";
import LLMConfigModal from "./LLMConfigModal"; import LLMConfigModal from "./LLMConfigModal";
@ -17,6 +17,26 @@ const formatDateTime = (isoString: string): string => {
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}; };
const formatSrtTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
};
const downloadBlob = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
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);
};
type TabType = "live" | "sessions" | "keywords"; type TabType = "live" | "sessions" | "keywords";
const SUMMARY_TYPES = [ const SUMMARY_TYPES = [
@ -31,6 +51,7 @@ export const CCTranscriptionPanel: React.FC = () => {
const { const {
isRecording, isRecording,
completedSegments, completedSegments,
serverWindow,
currentSegment, currentSegment,
wordCount, wordCount,
elapsedSeconds, elapsedSeconds,
@ -38,7 +59,7 @@ export const CCTranscriptionPanel: React.FC = () => {
timetableContext, timetableContext,
startSession, startSession,
stopSession, stopSession,
saveSegment, updateServerWindow,
resetSession, resetSession,
tickElapsed, tickElapsed,
addCanvasEvent, addCanvasEvent,
@ -62,6 +83,7 @@ export const CCTranscriptionPanel: React.FC = () => {
} = useTranscriptionStore(); } = useTranscriptionStore();
const [activeTab, setActiveTab] = useState<TabType>("live"); const [activeTab, setActiveTab] = useState<TabType>("live");
const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments');
const [sessions, setSessions] = useState<TranscriptionSession[]>([]); const [sessions, setSessions] = useState<TranscriptionSession[]>([]);
const [sessionName, setSessionName] = useState("Untitled Session"); const [sessionName, setSessionName] = useState("Untitled Session");
const serviceRef = useRef<TranscriptionService | null>(null); const serviceRef = useRef<TranscriptionService | null>(null);
@ -87,7 +109,8 @@ export const CCTranscriptionPanel: React.FC = () => {
useEffect(() => { useEffect(() => {
const detectTimetable = async () => { const detectTimetable = async () => {
try { try {
const response = await fetch('http://192.168.0.64:8000/database/timetables/current-period'); const apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBase}/database/timetables/current-period`);
const data = await response.json(); const data = await response.json();
if (data.period_id) { if (data.period_id) {
setTimetableContext(data as TimetablePeriod); setTimetableContext(data as TimetablePeriod);
@ -127,14 +150,16 @@ export const CCTranscriptionPanel: React.FC = () => {
try { try {
await startSession(timetableContext || undefined); await startSession(timetableContext || undefined);
const service = new TranscriptionService(); const service = new TranscriptionService();
service.setTranscriptionCallback((text, isFinal, metadata) => { service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => {
saveSegment(text, isFinal, metadata); updateServerWindow(segs, isLastLive);
if (isFinal) {
const { elapsedSeconds: elapsed } = useTranscriptionStore.getState();
checkSegmentForKeywords(text, elapsed);
}
}); });
await service.startTranscription(); service.setDisconnectCallback(() => {
console.warn('[CCTranscriptionPanel] WebSocket disconnected unexpectedly — resetting session');
serviceRef.current = null;
stopSession();
});
const whisperModel = useTranscriptionStore.getState().llmConfig.whisperModel || 'large-v3';
await service.startTranscription({ modelSize: whisperModel });
serviceRef.current = service; serviceRef.current = service;
// Initialize canvas event logger if session was created // Initialize canvas event logger if session was created
@ -176,16 +201,17 @@ export const CCTranscriptionPanel: React.FC = () => {
setSessions(loaded); setSessions(loaded);
}; };
// Generate summary handler // Generate summary — calls LLM providers directly, no backend proxy needed
const handleGenerateSummary = async () => { const handleGenerateSummary = async () => {
if (!activeSession) { const config = useTranscriptionStore.getState().llmConfig;
setSummaryError("No active session to generate summary for."); const allSegs = completedSegments;
if (allSegs.length === 0) {
setSummaryError("No transcription segments to summarise yet.");
return; return;
} }
if (!config.model) {
const config = useTranscriptionStore.getState().llmConfig; setSummaryError("Please configure an LLM model in Settings first.");
if (!config.apiKey) {
setSummaryError("Please configure your API key in Settings first.");
return; return;
} }
@ -193,30 +219,79 @@ export const CCTranscriptionPanel: React.FC = () => {
setSummaryError(null); setSummaryError(null);
setShowSummaryModal(false); setShowSummaryModal(false);
const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' ');
const promptMap: Record<string, string> = {
full_lesson: `You are an assistant helping a teacher review a lesson. Below is the full transcript of a lesson. Write a concise summary covering the main topics taught, key activities, and any notable moments.\n\nTranscript:\n${transcript}`,
questions_asked: `Below is a classroom transcript. List all the questions that were asked during the lesson, formatted as a numbered list.\n\nTranscript:\n${transcript}`,
teaching_style: `Below is a classroom transcript. Briefly describe the teaching style observed: instructional approach, student engagement techniques, pacing, and tone.\n\nTranscript:\n${transcript}`,
key_moments: `Below is a classroom transcript. Identify the 35 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
segment: `Summarise the following classroom transcript in 23 sentences.\n\nTranscript:\n${transcript}`,
};
const prompt = promptMap[summaryType] || promptMap.full_lesson;
try { try {
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; let summaryResult = '';
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, {
if (config.provider === 'ollama') {
const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.apiKey && config.apiKey !== 'sk-dummy') headers['Authorization'] = `Bearer ${config.apiKey}`;
const res = await fetch(`${base}/api/chat`, {
method: 'POST',
headers,
body: JSON.stringify({
model: config.model,
messages: [{ role: 'user', content: prompt }],
stream: false,
}),
});
if (!res.ok) throw new Error(`Ollama error ${res.status}: ${await res.text()}`);
const d = await res.json();
summaryResult = d.message?.content ?? JSON.stringify(d);
} else if (config.provider === 'openai' || config.provider === 'openrouter') {
const base = config.provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1';
const res = await fetch(`${base}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` },
body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }] }),
});
if (!res.ok) throw new Error(`${config.provider} error ${res.status}: ${await res.text()}`);
const d = await res.json();
summaryResult = d.choices?.[0]?.message?.content ?? JSON.stringify(d);
} else if (config.provider === 'anthropic') {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': config.apiKey,
'anthropic-version': '2023-06-01',
}, },
body: JSON.stringify({ body: JSON.stringify({ model: config.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
summary_type: summaryType,
provider: config.provider,
model: config.model,
api_key: config.apiKey,
}),
}); });
if (!res.ok) throw new Error(`Anthropic error ${res.status}: ${await res.text()}`);
const d = await res.json();
summaryResult = d.content?.[0]?.text ?? JSON.stringify(d);
if (!response.ok) { } else if (config.provider === 'google') {
const errorData = await response.json().catch(() => null); const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`); const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
});
if (!res.ok) throw new Error(`Google error ${res.status}: ${await res.text()}`);
const d = await res.json();
summaryResult = d.candidates?.[0]?.content?.parts?.[0]?.text ?? JSON.stringify(d);
} else {
throw new Error(`Unknown provider: ${config.provider}`);
} }
const data = await response.json(); setSummaryText(summaryResult);
// The API returns the summary text in the response
const summary = data.summary || data.content || data.text || JSON.stringify(data);
setSummaryText(summary);
} catch (error) { } catch (error) {
console.error('Failed to generate summary:', error); console.error('Failed to generate summary:', error);
setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary'); setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary');
@ -411,38 +486,49 @@ export const CCTranscriptionPanel: React.FC = () => {
</> </>
)} )}
{/* Export button */} {/* Export button — available whenever there are completed segments */}
{activeSession && ( {completedSegments.length > 0 && (
<> <>
<div className="panel-divider" /> <div className="panel-divider" />
<div className="panel-section"> <div className="panel-section">
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}> <div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
Export Session Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
</div> </div>
<div style={{ display: "flex", gap: "8px" }}> <div style={{ display: "flex", gap: "8px" }}>
{(['srt', 'txt', 'json'] as const).map((format) => ( {(['srt', 'txt', 'json'] as const).map((format) => (
<button <button
key={format} key={format}
onClick={async () => { onClick={() => {
try { // Build the segment list from store state — always matches what's displayed.
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const allSegs = [
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/export`, { ...completedSegments,
method: 'POST', ...(currentSegment && currentSegment.text.trim() ? [{ ...currentSegment, isFinal: true }] : []),
headers: { 'Content-Type': 'application/json' }, ];
body: JSON.stringify({ format }), const sessionTag = activeSession?.id.slice(0, 8) ?? 'session';
}); const filename = `${sessionTag}.${format}`;
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob(); if (format === 'srt') {
const url = window.URL.createObjectURL(blob); const content = allSegs
const a = document.createElement('a'); .filter(s => s.text.trim())
a.href = url; .map((seg, i) =>
a.download = `session_${activeSession.id.slice(0,8)}_${format}`; `${i + 1}\n${formatSrtTime(seg.start)} --> ${formatSrtTime(seg.end)}\n${seg.text.trim()}\n`
document.body.appendChild(a); )
a.click(); .join('\n');
window.URL.revokeObjectURL(url); downloadBlob(content, filename, 'text/plain');
document.body.removeChild(a); } else if (format === 'txt') {
} catch (error) { const content = allSegs.map(s => s.text.trim()).filter(Boolean).join('\n');
console.error('Export failed:', error); downloadBlob(content, filename, 'text/plain');
} else {
const content = JSON.stringify({
exported_at: new Date().toISOString(),
segment_count: allSegs.length,
segments: allSegs.map(s => ({
start: s.start,
end: s.end,
text: s.text.trim(),
})),
}, null, 2);
downloadBlob(content, filename, 'application/json');
} }
}} }}
style={{ style={{
@ -531,41 +617,129 @@ export const CCTranscriptionPanel: React.FC = () => {
{/* Live feed */} {/* Live feed */}
<div className="panel-section" style={{ gap: "6px" }}> <div className="panel-section" style={{ gap: "6px" }}>
<div className="panel-section-title">Live Feed</div> {/* Header row: title + view mode toggle */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
{completedSegments.map((seg, i) => ( <div className="panel-section-title" style={{ margin: 0 }}>Live Feed</div>
<div <div style={{ display: "flex", gap: "3px" }}>
key={"completed-" + i} {(["segments", "transcript"] as const).map(mode => (
<button
key={mode}
onClick={() => setViewMode(mode)}
style={{ style={{
padding: "8px 10px", padding: "2px 8px",
backgroundColor: "var(--color-muted)", fontSize: "11px",
borderRadius: "4px", backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)",
color: viewMode === mode ? "#fff" : "var(--color-text-2)",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
fontSize: "13px", borderRadius: "3px",
color: "var(--color-text)", cursor: "pointer",
lineHeight: 1.4, textTransform: "capitalize",
}} }}
> >
{mode}
</button>
))}
</div>
</div>
{(() => {
const allFinal = completedSegments; // already merged from server on every message
if (viewMode === "segments") {
return (
<>
{allFinal.map((seg, i) => (
<div
key={"seg-" + i}
style={{
padding: "7px 10px",
backgroundColor: "var(--color-muted)",
borderRadius: "6px",
border: "1px solid var(--color-divider)",
}}
>
<div style={{
fontFamily: "monospace",
fontSize: "10px",
color: "var(--color-text-2)",
marginBottom: "3px",
letterSpacing: "0.03em",
}}>
{formatTime(Math.floor(seg.start))} {formatTime(Math.floor(seg.end))}
</div>
<div style={{ fontSize: "13px", color: "var(--color-text)", lineHeight: 1.5 }}>
{seg.text} {seg.text}
</div> </div>
</div>
))} ))}
{currentSegment && ( {currentSegment && (
<div <div style={{
style={{ padding: "7px 10px",
padding: "8px 10px",
backgroundColor: "var(--color-panel)", backgroundColor: "var(--color-panel)",
borderRadius: "4px", borderRadius: "6px",
border: "1px dashed var(--color-divider)", border: "1px dashed var(--color-divider)",
fontSize: "13px", }}>
color: "var(--color-text-2)", <div style={{
fontStyle: "italic", fontFamily: "monospace",
lineHeight: 1.4, fontSize: "10px",
}} color: "var(--color-text-3)",
> marginBottom: "3px",
{currentSegment.text || "Listening..."} letterSpacing: "0.03em",
}}>
{formatTime(Math.floor(currentSegment.start))}
</div>
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
{currentSegment.text || "Listening…"}
</div>
</div> </div>
)} )}
</>
);
}
// Transcript view — single joined box + separate live segment
const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" ");
return (
<>
{(joinedText || !currentSegment) && (
<div style={{
padding: "10px 12px",
backgroundColor: "var(--color-muted)",
borderRadius: "6px",
border: "1px solid var(--color-divider)",
fontSize: "13px",
color: "var(--color-text)",
lineHeight: 1.7,
minHeight: "48px",
}}>
{joinedText || <span style={{ color: "var(--color-text-2)" }}>No completed segments yet.</span>}
</div>
)}
{currentSegment && (
<div style={{
padding: "7px 10px",
backgroundColor: "var(--color-panel)",
borderRadius: "6px",
border: "1px dashed var(--color-divider)",
}}>
<div style={{
fontFamily: "monospace",
fontSize: "10px",
color: "var(--color-text-3)",
marginBottom: "3px",
}}>
{formatTime(Math.floor(currentSegment.start))}
</div>
<div style={{ fontSize: "13px", color: "var(--color-text-2)", lineHeight: 1.5, fontStyle: "italic" }}>
{currentSegment.text || "Listening…"}
</div>
</div>
)}
</>
);
})()}
{!isRecording && completedSegments.length === 0 && !currentSegment && ( {!isRecording && completedSegments.length === 0 && !currentSegment && (
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}> <div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
@ -806,67 +980,86 @@ export const CCTranscriptionPanel: React.FC = () => {
{/* Summary Type Selection Modal */} {/* Summary Type Selection Modal */}
{showSummaryModal && ( {showSummaryModal && (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div <div
className="fixed inset-0 bg-black/50 transition-opacity" style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setShowSummaryModal(false)} onMouseDown={(e) => { if (e.target === e.currentTarget) setShowSummaryModal(false); }}
/> >
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-sm mx-auto"> <div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200"> <div
<h3 className="text-lg font-medium leading-6 text-gray-900"> style={{
Generate Summary position: 'relative',
</h3> width: '100%',
maxWidth: '360px',
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
borderRadius: '10px',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
overflow: 'hidden',
zIndex: 1,
}}
onMouseDown={(e) => e.stopPropagation()}
>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--color-divider)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>Generate Summary</span>
<button <button
onClick={() => setShowSummaryModal(false)} onClick={() => setShowSummaryModal(false)}
className="text-gray-400 hover:text-gray-500 focus:outline-none" style={{ padding: '2px 6px', border: 'none', backgroundColor: 'transparent', color: 'var(--color-text-2)', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }}
> >
<Close sx={{ fontSize: 20 }} /> ×
</button> </button>
</div> </div>
<div className="px-4 py-5 sm:p-6 space-y-4"> <div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-2)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Summary Type Summary Type
</label> </label>
<select <select
value={summaryType} value={summaryType}
onChange={(e) => setSummaryType(e.target.value)} onChange={(e) => setSummaryType(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" style={{ width: '100%', padding: '7px 10px', border: '1px solid var(--color-divider)', borderRadius: '6px', backgroundColor: 'var(--color-muted)', color: 'var(--color-text)', fontSize: '13px', outline: 'none' }}
> >
{SUMMARY_TYPES.map((t) => ( {SUMMARY_TYPES.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>{t.label}</option>
{t.label}
</option>
))} ))}
</select> </select>
</div> </div>
{/* Config status indicator */}
<div style={{ <div style={{
padding: "8px", padding: '8px 10px',
borderRadius: "6px", borderRadius: '6px',
fontSize: "12px", fontSize: '12px',
backgroundColor: llmConfig.apiKey ? "#f0fdf4" : "#fef2f2", backgroundColor: llmConfig.model ? 'var(--color-muted)' : '#fef2f2',
color: llmConfig.apiKey ? "#16a34a" : "#dc2626", color: llmConfig.model ? 'var(--color-text-2)' : '#dc2626',
border: `1px solid ${llmConfig.apiKey ? "#bbf7d0" : "#fecaca"}`, border: '1px solid var(--color-divider)',
}}> }}>
{llmConfig.apiKey ? ( {llmConfig.model
<> Configured: {llmConfig.provider} ({llmConfig.model || 'default model'})</> ? <> {llmConfig.provider} · {llmConfig.model}</>
) : ( : <> No model configured open Settings first</>
<> No API key configured. Click the icon to set up.</> }
)}
</div> </div>
<button <button
onClick={handleGenerateSummary} onClick={handleGenerateSummary}
disabled={isGeneratingSummary || !llmConfig.apiKey} disabled={isGeneratingSummary || !llmConfig.model}
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${ style={{
isGeneratingSummary || !llmConfig.apiKey padding: '9px',
? 'bg-gray-400 cursor-not-allowed' border: 'none',
: 'bg-purple-600 hover:bg-purple-700' borderRadius: '6px',
}`} backgroundColor: isGeneratingSummary || !llmConfig.model ? '#9ca3af' : '#7c3aed',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: isGeneratingSummary || !llmConfig.model ? 'not-allowed' : 'pointer',
opacity: isGeneratingSummary || !llmConfig.model ? 0.7 : 1,
}}
> >
{isGeneratingSummary ? 'Generating...' : 'Generate'} {isGeneratingSummary ? 'Generating' : 'Generate'}
</button> </button>
</div> </div>
</div> </div>