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