feat(cis): add CCTranscriptionPanel Live tab to sidebar (Phase 1)

- Add CCTranscriptionPanel component with Live tab
- Add Zustand transcriptionStore for session state management
- Wire panel into BasePanel sidebar system
- Fix merged switch cases in getIconForPanel, getDescriptionForPanel, renderCurrentPanel
- Add VITE_WHISPERLIVE_URL to .env
This commit is contained in:
Kevin Carter 2026-05-20 21:34:21 +00:00
parent 0f4956d4a4
commit 2ee4e4afe7
3 changed files with 241 additions and 1 deletions

View File

@ -0,0 +1,68 @@
import { create } from 'zustand';
export interface TranscriptionSegment {
text: string;
isFinal: boolean;
start: number;
end: number;
}
interface TranscriptionState {
isRecording: boolean;
isConnecting: boolean;
completedSegments: TranscriptionSegment[];
currentSegment: TranscriptionSegment | null;
wordCount: number;
elapsedSeconds: number;
startSession: () => void;
stopSession: () => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => void;
resetSession: () => void;
tickElapsed: () => void;
}
export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false,
isConnecting: false,
completedSegments: [],
currentSegment: null,
wordCount: 0,
elapsedSeconds: 0,
startSession: () => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0 });
},
stopSession: () => {
set({ isRecording: false, isConnecting: false });
},
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
const { completedSegments, currentSegment } = get();
if (isFinal) {
// Final segment — move current to completed, clear current
const newCompleted = [...completedSegments];
if (currentSegment && currentSegment.text.trim()) {
newCompleted.push({ ...currentSegment, isFinal: true });
}
const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
} else {
// In-progress segment
set({ currentSegment: { text, isFinal: false, ...metadata } });
}
},
resetSession: () => {
set({ isRecording: false, isConnecting: false, completedSegments: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0 });
},
tickElapsed: () => {
set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 }));
},
}));

View File

@ -23,7 +23,8 @@ import {
Search as SearchIcon, Search as SearchIcon,
Navigation as NavigationIcon, Navigation as NavigationIcon,
Save as NodeIcon, Save as NodeIcon,
Assignment as ExamIcon Assignment as ExamIcon,
Mic as MicIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel'; import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel'; import { CCSlidesPanel } from './CCSlidesPanel';
@ -33,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel'; import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel'; import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel' import { CCSearchPanel } from './CCSearchPanel'
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles'; import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
import './panel.css'; import './panel.css';
// import { CCNavigationPanel } from './navigation/CCNavigationPanel'; // import { CCNavigationPanel } from './navigation/CCNavigationPanel';
@ -47,6 +49,7 @@ export const PANEL_TYPES = {
{ id: 'node-snapshot', label: 'Node', order: 20 }, { id: 'node-snapshot', label: 'Node', order: 20 },
{ id: 'files', label: 'Files', order: 25 }, { id: 'files', label: 'Files', order: 25 },
{ id: 'cc-shapes', label: 'Shapes', order: 30 }, { id: 'cc-shapes', label: 'Shapes', order: 30 },
{ id: 'transcription', label: 'Transcription', order: 35 },
{ id: 'slides', label: 'Slides', order: 40 }, { id: 'slides', label: 'Slides', order: 40 },
{ id: 'youtube', label: 'YouTube', order: 50 }, { id: 'youtube', label: 'YouTube', order: 50 },
{ id: 'graph', label: 'Graph', order: 60 }, { id: 'graph', label: 'Graph', order: 60 },
@ -208,6 +211,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
return <NavigationIcon />; return <NavigationIcon />;
case 'cc-shapes': case 'cc-shapes':
return <ShapesIcon />; return <ShapesIcon />;
case 'transcription':
return <MicIcon />;
case 'slides': case 'slides':
return <SlidesIcon />; return <SlidesIcon />;
case 'youtube': case 'youtube':
@ -231,6 +236,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
switch (panelId) { switch (panelId) {
case 'cabinets': case 'cabinets':
return 'Manage file cabinets'; return 'Manage file cabinets';
case 'transcription':
return 'Record and transcribe lessons';
case 'cc-shapes': case 'cc-shapes':
return 'Add shapes and elements to your canvas'; return 'Add shapes and elements to your canvas';
case 'slides': case 'slides':
@ -260,6 +267,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
switch (currentPanelType) { switch (currentPanelType) {
case 'cabinets': case 'cabinets':
return <CCCabinetsPanel />; return <CCCabinetsPanel />;
case 'transcription':
return <CCTranscriptionPanel />;
case 'files': case 'files':
return <CCFilesPanel />; return <CCFilesPanel />;
case 'cc-shapes': case 'cc-shapes':

View File

@ -0,0 +1,163 @@
import React, { useEffect, useRef, useState } from "react";
import { Mic as MicIcon, Stop as StopIcon } from "@mui/icons-material";
import { useTranscriptionStore, TranscriptionSegment } from "../../../../../stores/transcriptionStore";
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
import "./panel.css";
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m.toString().padStart(2, "0") + ":" + s.toString().padStart(2, "0");
};
export const CCTranscriptionPanel: React.FC = () => {
const {
isRecording,
completedSegments,
currentSegment,
wordCount,
elapsedSeconds,
startSession,
stopSession,
saveSegment,
resetSession,
tickElapsed,
} = useTranscriptionStore();
const [sessionName] = useState("Untitled Session");
const serviceRef = useRef<TranscriptionService | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (isRecording) {
timerRef.current = setInterval(tickElapsed, 1000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isRecording, tickElapsed]);
const handleStart = async () => {
try {
startSession();
const service = new TranscriptionService();
service.setTranscriptionCallback((text, isFinal, metadata) => {
saveSegment(text, isFinal, metadata);
});
await service.startTranscription();
serviceRef.current = service;
} catch (error) {
console.error("Failed to start transcription:", error);
stopSession();
}
};
const handleStop = () => {
if (serviceRef.current) {
serviceRef.current.stopTranscription();
serviceRef.current = null;
}
stopSession();
};
useEffect(() => {
return () => {
if (serviceRef.current) {
serviceRef.current.stopTranscription();
serviceRef.current = null;
}
};
}, []);
return (
<div className="panel-container">
<div className="panel-section">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "14px", fontWeight: 500, color: "var(--color-text)" }}>
{sessionName}
</span>
<div style={{ display: "flex", gap: "12px", fontSize: "12px", color: "var(--color-text-2)" }}>
<span>{wordCount} words</span>
<span>{formatTime(elapsedSeconds)}</span>
</div>
</div>
</div>
<div className="panel-divider" />
<div className="panel-section">
<button
onClick={isRecording ? handleStop : handleStart}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "16px",
width: "100%",
borderRadius: "8px",
border: "none",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
color: "#fff",
backgroundColor: isRecording ? "#ef4444" : "var(--color-text)",
transition: "background-color 200ms ease",
}}
>
{isRecording ? <StopIcon /> : <MicIcon />}
{isRecording ? "Stop Recording" : "Start Recording"}
</button>
</div>
<div className="panel-divider" />
<div className="panel-section" style={{ gap: "6px" }}>
<div className="panel-section-title">Live Feed</div>
{completedSegments.map((seg, i) => (
<div
key={"completed-" + i}
style={{
padding: "8px 10px",
backgroundColor: "#fff",
borderRadius: "4px",
border: "1px solid var(--color-divider)",
fontSize: "13px",
color: "var(--color-text)",
lineHeight: 1.4,
}}
>
{seg.text}
</div>
))}
{currentSegment && (
<div
style={{
padding: "8px 10px",
backgroundColor: "var(--color-panel)",
borderRadius: "4px",
border: "1px dashed var(--color-divider)",
fontSize: "13px",
color: "var(--color-text-2)",
fontStyle: "italic",
lineHeight: 1.4,
}}
>
{currentSegment.text || "Listening..."}
</div>
)}
{!isRecording && completedSegments.length === 0 && !currentSegment && (
<div style={{ textAlign: "center", color: "var(--color-text-2)", padding: "16px", fontSize: "13px" }}>
Press Start Recording to begin transcription
</div>
)}
</div>
</div>
);
};