From 5284d30f841e3bbe6bd2875b5ffb979fcdd478c2 Mon Sep 17 00:00:00 2001 From: kcar Date: Mon, 25 May 2026 13:41:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(panels):=20resolve=20sidebar=20refresh=20bu?= =?UTF-8?q?gs=20=E2=80=94=20getUser=E2=86=92getSession,=20files=20double-c?= =?UTF-8?q?all,=20hardcoded=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/stores/transcriptionStore.ts | 204 ++++++-- .../components/shared/CCFilesPanel.tsx | 8 +- .../shared/CCTranscriptionPanel.tsx | 453 +++++++++++++----- 3 files changed, 502 insertions(+), 163 deletions(-) diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index bf310d8..25455bb 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -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; stopSession: () => Promise; + updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise; resetSession: () => void; tickElapsed: () => void; @@ -156,6 +168,7 @@ export const useTranscriptionStore = create((set, get) => ({ isConnecting: false, activeSession: null, completedSegments: [], + serverWindow: [], currentSegment: null, pendingCanvasEvents: [], timetableContext: null, @@ -187,14 +200,14 @@ export const useTranscriptionStore = create((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((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((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((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((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((set, get) => ({ isRecording: false, isConnecting: false, completedSegments: [], + serverWindow: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0, @@ -321,7 +465,7 @@ export const useTranscriptionStore = create((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((set, get) => ({ loadSessions: async (): Promise => { 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((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((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', diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx index 881cc4b..eb5f802 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanel.tsx @@ -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([]); @@ -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; diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index 6cd8ef2..b2922b7 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -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("live"); + const [viewMode, setViewMode] = useState<'segments' | 'transcript'>('segments'); const [sessions, setSessions] = useState([]); const [sessionName, setSessionName] = useState("Untitled Session"); const serviceRef = useRef(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); - try { - const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; - const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${activeSession.id}/summaries`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - summary_type: summaryType, - provider: config.provider, - model: config.model, - api_key: config.apiKey, - }), - }); + const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' '); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`); + const promptMap: Record = { + 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 { + let summaryResult = ''; + + if (config.provider === 'ollama') { + const base = (config.baseUrl || 'https://ollama.kevlarai.com').replace(/\/$/, ''); + const headers: Record = { '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({ 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); + + } 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 && ( <>
- Export Session + Export ({completedSegments.length} segment{completedSegments.length !== 1 ? 's' : ''})
{(['srt', 'txt', 'json'] as const).map((format) => ( + ))}
- ))} +
- {currentSegment && ( -
- {currentSegment.text || "Listening..."} -
- )} + {(() => { + const allFinal = completedSegments; // already merged from server on every message + + if (viewMode === "segments") { + return ( + <> + {allFinal.map((seg, i) => ( +
+
+ {formatTime(Math.floor(seg.start))} → {formatTime(Math.floor(seg.end))} +
+
+ {seg.text} +
+
+ ))} + + {currentSegment && ( +
+
+ {formatTime(Math.floor(currentSegment.start))} → … +
+
+ {currentSegment.text || "Listening…"} +
+
+ )} + + ); + } + + // Transcript view — single joined box + separate live segment + const joinedText = allFinal.map(s => s.text.trim()).filter(Boolean).join(" "); + return ( + <> + {(joinedText || !currentSegment) && ( +
+ {joinedText || No completed segments yet.} +
+ )} + + {currentSegment && ( +
+
+ {formatTime(Math.floor(currentSegment.start))} → … +
+
+ {currentSegment.text || "Listening…"} +
+
+ )} + + ); + })()} {!isRecording && completedSegments.length === 0 && !currentSegment && (
@@ -806,67 +980,86 @@ export const CCTranscriptionPanel: React.FC = () => { {/* Summary Type Selection Modal */} {showSummaryModal && ( -
+
{ if (e.target === e.currentTarget) setShowSummaryModal(false); }} + > +
setShowSummaryModal(false)} - /> -
-
-

- Generate Summary -

+ 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()} + > +
+ Generate Summary
-
+
-
- {/* Config status indicator */}
- {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 + }