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:
parent
fb1795fd2b
commit
5284d30f84
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
|
||||
const { completedSegments, currentSegment, activeSession, wordCount } = get();
|
||||
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;
|
||||
|
||||
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(
|
||||
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||
0
|
||||
);
|
||||
|
||||
set({
|
||||
serverWindow: segments,
|
||||
completedSegments: newCompleted,
|
||||
currentSegment: null,
|
||||
currentSegment: newCurrentSegment,
|
||||
wordCount: newWordCount,
|
||||
});
|
||||
},
|
||||
|
||||
// Save to Supabase if session is active
|
||||
if (activeSession) {
|
||||
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 {
|
||||
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',
|
||||
|
||||
@ -115,6 +115,7 @@ export const CCFilesPanel: React.FC = () => {
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<{ id: string | null; name: string }[]>([
|
||||
{ id: null, name: 'Root' }
|
||||
]);
|
||||
const initialSelectionDone = useRef(false);
|
||||
|
||||
// Directory upload state
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileWithPath[]>([]);
|
||||
@ -158,13 +159,16 @@ export const CCFilesPanel: React.FC = () => {
|
||||
const data = await apiFetch('/database/cabinets');
|
||||
const all = [...(data.owned || []), ...(data.shared || [])];
|
||||
setCabinets(all);
|
||||
if (all.length && !selectedCabinet) setSelectedCabinet(all[0].id);
|
||||
if (all.length && !initialSelectionDone.current) {
|
||||
initialSelectionDone.current = true;
|
||||
setSelectedCabinet(all[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cabinets:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCabinet, apiFetch]);
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadFiles = useCallback(async (cabinetId: string, page: number = currentPage) => {
|
||||
if (!cabinetId) return;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 { 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 { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger";
|
||||
import LLMConfigModal from "./LLMConfigModal";
|
||||
@ -17,6 +17,26 @@ const formatDateTime = (isoString: string): string => {
|
||||
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";
|
||||
|
||||
const SUMMARY_TYPES = [
|
||||
@ -31,6 +51,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
const {
|
||||
isRecording,
|
||||
completedSegments,
|
||||
serverWindow,
|
||||
currentSegment,
|
||||
wordCount,
|
||||
elapsedSeconds,
|
||||
@ -38,7 +59,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
timetableContext,
|
||||
startSession,
|
||||
stopSession,
|
||||
saveSegment,
|
||||
updateServerWindow,
|
||||
resetSession,
|
||||
tickElapsed,
|
||||
addCanvasEvent,
|
||||
@ -62,6 +83,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
} = useTranscriptionStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>("live");
|
||||
const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments');
|
||||
const [sessions, setSessions] = useState<TranscriptionSession[]>([]);
|
||||
const [sessionName, setSessionName] = useState("Untitled Session");
|
||||
const serviceRef = useRef<TranscriptionService | null>(null);
|
||||
@ -87,7 +109,8 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const detectTimetable = async () => {
|
||||
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();
|
||||
if (data.period_id) {
|
||||
setTimetableContext(data as TimetablePeriod);
|
||||
@ -127,14 +150,16 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
try {
|
||||
await startSession(timetableContext || undefined);
|
||||
const service = new TranscriptionService();
|
||||
service.setTranscriptionCallback((text, isFinal, metadata) => {
|
||||
saveSegment(text, isFinal, metadata);
|
||||
if (isFinal) {
|
||||
const { elapsedSeconds: elapsed } = useTranscriptionStore.getState();
|
||||
checkSegmentForKeywords(text, elapsed);
|
||||
}
|
||||
service.setServerSegmentsCallback((segs: ServerSegment[], isLastLive: boolean) => {
|
||||
updateServerWindow(segs, isLastLive);
|
||||
});
|
||||
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;
|
||||
|
||||
// Initialize canvas event logger if session was created
|
||||
@ -176,16 +201,17 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
setSessions(loaded);
|
||||
};
|
||||
|
||||
// Generate summary handler
|
||||
// Generate summary — calls LLM providers directly, no backend proxy needed
|
||||
const handleGenerateSummary = async () => {
|
||||
if (!activeSession) {
|
||||
setSummaryError("No active session to generate summary for.");
|
||||
const config = useTranscriptionStore.getState().llmConfig;
|
||||
const allSegs = completedSegments;
|
||||
|
||||
if (allSegs.length === 0) {
|
||||
setSummaryError("No transcription segments to summarise yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = useTranscriptionStore.getState().llmConfig;
|
||||
if (!config.apiKey) {
|
||||
setSummaryError("Please configure your API key in Settings first.");
|
||||
if (!config.model) {
|
||||
setSummaryError("Please configure an LLM model in Settings first.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -193,30 +219,79 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
setSummaryError(null);
|
||||
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 3–5 most significant moments or turning points in the lesson.\n\nTranscript:\n${transcript}`,
|
||||
segment: `Summarise the following classroom transcript in 2–3 sentences.\n\nTranscript:\n${transcript}`,
|
||||
};
|
||||
|
||||
const prompt = promptMap[summaryType] || promptMap.full_lesson;
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, {
|
||||
let summaryResult = '';
|
||||
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
summary_type: summaryType,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
api_key: config.apiKey,
|
||||
}),
|
||||
body: JSON.stringify({ model: config.model, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
|
||||
});
|
||||
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) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`);
|
||||
} else if (config.provider === 'google') {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;
|
||||
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();
|
||||
// The API returns the summary text in the response
|
||||
const summary = data.summary || data.content || data.text || JSON.stringify(data);
|
||||
setSummaryText(summary);
|
||||
setSummaryText(summaryResult);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate summary:', error);
|
||||
setSummaryError(error instanceof Error ? error.message : 'Failed to generate summary');
|
||||
@ -411,38 +486,49 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Export button */}
|
||||
{activeSession && (
|
||||
{/* Export button — available whenever there are completed segments */}
|
||||
{completedSegments.length > 0 && (
|
||||
<>
|
||||
<div className="panel-divider" />
|
||||
<div className="panel-section">
|
||||
<div style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)", marginBottom: "8px" }}>
|
||||
Export Session
|
||||
Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{(['srt', 'txt', 'json'] as const).map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ format }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `session_${activeSession.id.slice(0,8)}_${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
onClick={() => {
|
||||
// Build the segment list from store state — always matches what's displayed.
|
||||
const allSegs = [
|
||||
...completedSegments,
|
||||
...(currentSegment && currentSegment.text.trim() ? [{ ...currentSegment, isFinal: true }] : []),
|
||||
];
|
||||
const sessionTag = activeSession?.id.slice(0, 8) ?? 'session';
|
||||
const filename = `${sessionTag}.${format}`;
|
||||
|
||||
if (format === 'srt') {
|
||||
const content = allSegs
|
||||
.filter(s => s.text.trim())
|
||||
.map((seg, i) =>
|
||||
`${i + 1}\n${formatSrtTime(seg.start)} --> ${formatSrtTime(seg.end)}\n${seg.text.trim()}\n`
|
||||
)
|
||||
.join('\n');
|
||||
downloadBlob(content, filename, 'text/plain');
|
||||
} else if (format === 'txt') {
|
||||
const content = allSegs.map(s => s.text.trim()).filter(Boolean).join('\n');
|
||||
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={{
|
||||
@ -531,41 +617,129 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
|
||||
{/* Live feed */}
|
||||
<div className="panel-section" style={{ gap: "6px" }}>
|
||||
<div className="panel-section-title">Live Feed</div>
|
||||
|
||||
{completedSegments.map((seg, i) => (
|
||||
<div
|
||||
key={"completed-" + i}
|
||||
{/* Header row: title + view mode toggle */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div className="panel-section-title" style={{ margin: 0 }}>Live Feed</div>
|
||||
<div style={{ display: "flex", gap: "3px" }}>
|
||||
{(["segments", "transcript"] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
backgroundColor: "var(--color-muted)",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 8px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)",
|
||||
color: viewMode === mode ? "#fff" : "var(--color-text-2)",
|
||||
border: "1px solid var(--color-divider)",
|
||||
fontSize: "13px",
|
||||
color: "var(--color-text)",
|
||||
lineHeight: 1.4,
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentSegment && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
<div style={{
|
||||
padding: "7px 10px",
|
||||
backgroundColor: "var(--color-panel)",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "6px",
|
||||
border: "1px dashed var(--color-divider)",
|
||||
fontSize: "13px",
|
||||
color: "var(--color-text-2)",
|
||||
fontStyle: "italic",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{currentSegment.text || "Listening..."}
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "10px",
|
||||
color: "var(--color-text-3)",
|
||||
marginBottom: "3px",
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 && (
|
||||
<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 */}
|
||||
{showSummaryModal && (
|
||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={() => 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 className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
Generate Summary
|
||||
</h3>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowSummaryModal(false); }}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6 space-y-4">
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
value={summaryType}
|
||||
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) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Config status indicator */}
|
||||
<div style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
backgroundColor: llmConfig.apiKey ? "#f0fdf4" : "#fef2f2",
|
||||
color: llmConfig.apiKey ? "#16a34a" : "#dc2626",
|
||||
border: `1px solid ${llmConfig.apiKey ? "#bbf7d0" : "#fecaca"}`,
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: llmConfig.model ? 'var(--color-muted)' : '#fef2f2',
|
||||
color: llmConfig.model ? 'var(--color-text-2)' : '#dc2626',
|
||||
border: '1px solid var(--color-divider)',
|
||||
}}>
|
||||
{llmConfig.apiKey ? (
|
||||
<>✓ Configured: {llmConfig.provider} ({llmConfig.model || 'default model'})</>
|
||||
) : (
|
||||
<>⚠ No API key configured. Click the ⚙ icon to set up.</>
|
||||
)}
|
||||
{llmConfig.model
|
||||
? <>✓ {llmConfig.provider} · {llmConfig.model}</>
|
||||
: <>⚠ No model configured — open Settings first</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateSummary}
|
||||
disabled={isGeneratingSummary || !llmConfig.apiKey}
|
||||
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
|
||||
isGeneratingSummary || !llmConfig.apiKey
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 hover:bg-purple-700'
|
||||
}`}
|
||||
disabled={isGeneratingSummary || !llmConfig.model}
|
||||
style={{
|
||||
padding: '9px',
|
||||
border: 'none',
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user