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,
|
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':
|
||||||
|
|||||||
@ -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