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;
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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 }) => {
|
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
|
||||||
const { completedSegments, currentSegment, activeSession, wordCount } = get();
|
const { completedSegments, currentSegment, activeSession } = get();
|
||||||
|
|
||||||
if (isFinal) {
|
if (isFinal) {
|
||||||
// Final segment — append the finalized text directly (not currentSegment, which
|
// Deduplicate by start time: if a segment with this start already exists, update it
|
||||||
// may lag behind or duplicate when WhisperLive re-sends the full segments array).
|
// rather than appending. This prevents doubles when the stability timer fires and
|
||||||
const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
|
// 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(
|
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({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
|
||||||
completedSegments: newCompleted,
|
|
||||||
currentSegment: null,
|
|
||||||
wordCount: newWordCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to Supabase if session is active
|
if (isNew && activeSession) {
|
||||||
if (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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
try {
|
const transcript = allSegs.map(s => s.text.trim()).filter(Boolean).join(' ');
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const promptMap: Record<string, string> = {
|
||||||
const errorData = await response.json().catch(() => null);
|
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}`,
|
||||||
throw new Error(errorData?.detail || errorData?.error || `API error: ${response.status}`);
|
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<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({ 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();
|
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 => (
|
||||||
style={{
|
<button
|
||||||
padding: "8px 10px",
|
key={mode}
|
||||||
backgroundColor: "var(--color-muted)",
|
onClick={() => setViewMode(mode)}
|
||||||
borderRadius: "4px",
|
style={{
|
||||||
border: "1px solid var(--color-divider)",
|
padding: "2px 8px",
|
||||||
fontSize: "13px",
|
fontSize: "11px",
|
||||||
color: "var(--color-text)",
|
backgroundColor: viewMode === mode ? "#2563eb" : "var(--color-muted)",
|
||||||
lineHeight: 1.4,
|
color: viewMode === mode ? "#fff" : "var(--color-text-2)",
|
||||||
}}
|
border: "1px solid var(--color-divider)",
|
||||||
>
|
borderRadius: "3px",
|
||||||
{seg.text}
|
cursor: "pointer",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{currentSegment && (
|
{(() => {
|
||||||
<div
|
const allFinal = completedSegments; // already merged from server on every message
|
||||||
style={{
|
|
||||||
padding: "8px 10px",
|
if (viewMode === "segments") {
|
||||||
backgroundColor: "var(--color-panel)",
|
return (
|
||||||
borderRadius: "4px",
|
<>
|
||||||
border: "1px dashed var(--color-divider)",
|
{allFinal.map((seg, i) => (
|
||||||
fontSize: "13px",
|
<div
|
||||||
color: "var(--color-text-2)",
|
key={"seg-" + i}
|
||||||
fontStyle: "italic",
|
style={{
|
||||||
lineHeight: 1.4,
|
padding: "7px 10px",
|
||||||
}}
|
backgroundColor: "var(--color-muted)",
|
||||||
>
|
borderRadius: "6px",
|
||||||
{currentSegment.text || "Listening..."}
|
border: "1px solid var(--color-divider)",
|
||||||
</div>
|
}}
|
||||||
)}
|
>
|
||||||
|
<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: "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",
|
||||||
|
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 && (
|
{!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
|
||||||
|
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
|
<div
|
||||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
style={{
|
||||||
onClick={() => setShowSummaryModal(false)}
|
position: 'relative',
|
||||||
/>
|
width: '100%',
|
||||||
<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">
|
maxWidth: '360px',
|
||||||
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
backgroundColor: 'var(--color-panel)',
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
border: '1px solid var(--color-divider)',
|
||||||
Generate Summary
|
borderRadius: '10px',
|
||||||
</h3>
|
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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user