2026-10-09 13:12:52 +08:00
"use client" ;
import { useCallback , useEffect , useMemo , useRef , useState , type ChangeEvent } from "react" ;
import Timeline from "@/components/timeline" ;
2026-10-09 15:55:46 +08:00
import TranscriptEditor from "@/components/transcript-editor" ;
import { createEditHistory , commitEdit , undoEdit , redoEdit } from "@/lib/edit-history" ;
2026-10-09 13:12:52 +08:00
import { createAnnotation , exportAnnotations , exportTranscripts , formatTime , parseAnnotations , parseTranscripts , reasonOptions , validateAnnotation , type Annotation , type Transcript } from "@/lib/annotations" ;
type Recording = { call_id : string ; name : string ; path : string ; url : string };
type Dataset = { transcripts : Transcript []; annotations : Annotation [] };
2026-10-09 15:55:46 +08:00
type Workspace = Dataset & { recordings : Recording []; source : "saved" | "draft" | "example" ; savedAt? : string };
2026-10-09 13:12:52 +08:00
type Notice = { message : string ; kind : "success" | "error" | "info" };
const DRAFT_KEY = "eot-workbench-v1" ;
const DEFAULT_DATASET = "韩泰机器人抢话" ;
const ALL_DATASETS = "all" ;
function recordingDataset ( recording : Recording ) {
const parts = recording . path . replaceAll ( "\\" , "/" ). split ( "/" ). filter ( Boolean );
return parts . length > 1 ? parts [ 0 ] : "已导入对话" ;
}
function callDataset ( id : string , recordings : Recording [], originalGroups? : Map < string , string >) {
const original = originalGroups ? . get ( id );
if ( original ) return original ;
const recording = recordings . find ( row => row . call_id === id );
return recording ? recordingDataset ( recording ) : id === "demo" ? "标准示例" : "已导入对话" ;
}
2026-10-09 15:55:46 +08:00
type IconName = "wave" | "upload" | "download" | "save" | "search" | "play" | "pause" | "back" | "forward" | "plus" | "check" | "clock" | "help" | "edit" | "trash" | "undo" | "redo" | "headphones" | "chevron" | "close" | "file" ;
2026-10-09 13:12:52 +08:00
const paths : Record < IconName , string > = {
wave : "M3 10v4m4-8v12m5-16v20m5-16v12m4-8v4" , upload : "M12 16V3m-5 5 5-5 5 5M4 16v4h16v-4" , download : "M12 3v13m-5-5 5 5 5-5M4 17v3h16v-3" ,
save : "M5 3h13l3 3v15H3V3h2m2 0v6h10V3M7 21v-8h10v8" , search : "M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0" , play : "m9 5 11 7-11 7V5" , pause : "M8 5v14m8-14v14" ,
back : "M11 5 4 12l7 7M4 12h10a6 6 0 0 1 6 6" , forward : "m13 5 7 7-7 7M20 12H10a6 6 0 0 0-6 6" , plus : "M12 5v14M5 12h14" , check : "m5 12 4 4L19 6" , clock : "M12 7v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0" ,
help : "M9 9a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0" , edit : "m16 3 5 5-12 12-6 1 1-6L16 3m-2 2 5 5" , trash : "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7" ,
2026-10-09 15:55:46 +08:00
undo : "M8 3 3 8l5 5M3 8h11a7 7 0 0 1 0 14" , redo : "m16 3 5 5-5 5M21 8H10a7 7 0 0 0 0 14" , headphones : "M4 14v-2a8 8 0 0 1 16 0v2M4 13H2v7h5v-7H4m16 0h2v7h-5v-7h3" , chevron : "m6 9 6 6 6-6" , close : "m6 6 12 12M18 6 6 18" , file : "M14 2H5v20h14V7l-5-5v5h5M8 12h8m-8 4h6" ,
2026-10-09 13:12:52 +08:00
};
function Icon ({ name , size = 18 } : { name : IconName ; size? : number }) { return < svg width = { size } height = { size } viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.7" strokeLinecap = "round" strokeLinejoin = "round" aria-hidden = "true" >< path d = { paths [ name ]} /></ svg >; }
function labelName ( row : Annotation ) { return row . can_reply_now === "1" ? "可以接话" : row . can_reply_now === "0" ? "继续等待" : row . reason ? "待仲裁" : "待标注" ; }
function labelClass ( row : Annotation ) { return row . can_reply_now === "1" ? "positive" : row . can_reply_now === "0" ? "negative" : "pending" ; }
function download ( filename : string , content : string ) { const url = URL . createObjectURL ( new Blob ([ "\uFEFF" , content ], { type : "text/csv;charset=utf-8" })); const link = document . createElement ( "a" ); link . href = url ; link . download = filename ; link . click (); setTimeout (() => URL . revokeObjectURL ( url ), 1000 ); }
export default function Workbench() {
2026-10-09 15:55:46 +08:00
const [ history , setHistory ] = useState (() => createEditHistory < Dataset >({ transcripts : [], annotations : [] }));
const historyRef = useRef ( history );
const { transcripts , annotations } = history . present ;
2026-10-09 13:12:52 +08:00
const [ recordings , setRecordings ] = useState < Recording [] >([]);
const [ callId , setCallId ] = useState ( "demo" );
const [ selectedId , setSelectedId ] = useState < string | null >( null );
const [ currentMs , setCurrentMs ] = useState ( 0 );
const [ audioDuration , setAudioDuration ] = useState ( 0 );
2026-10-09 15:55:46 +08:00
const [ waveform , setWaveform ] = useState < number [][] | null >( null );
2026-10-09 13:12:52 +08:00
const [ playing , setPlaying ] = useState ( false );
const [ rate , setRate ] = useState ( 1 );
const [ loading , setLoading ] = useState ( true );
const [ initialized , setInitialized ] = useState ( false );
const [ dirty , setDirty ] = useState ( false );
const [ saving , setSaving ] = useState ( false );
const [ savedTime , setSavedTime ] = useState < string | null >( null );
const [ search , setSearch ] = useState ( "" );
const [ filter , setFilter ] = useState ( "all" );
const [ datasetScope , setDatasetScope ] = useState ( DEFAULT_DATASET );
const [ mode , setMode ] = useState < "edit" | "view" > ( "edit" );
2026-10-09 15:55:46 +08:00
const [ menu , setMenu ] = useState < "import" | "export" | "create" | null > ( null );
2026-10-09 13:12:52 +08:00
const [ notice , setNotice ] = useState < Notice | null >( null );
const [ transcriptDraft , setTranscriptDraft ] = useState < { index : number ; row : Transcript } | null > ( null );
const [ helpOpen , setHelpOpen ] = useState ( false );
const audioRef = useRef < HTMLAudioElement >( null );
const asrImportRef = useRef < HTMLInputElement >( null );
const eotImportRef = useRef < HTMLInputElement >( null );
const audioImportRef = useRef < HTMLInputElement >( null );
const playBoundary = useRef < number | null >( null );
const objectUrls = useRef ( new Set < string >());
const versionRef = useRef ( 0 );
const originalGroupsRef = useRef ( new Map < string , string >());
2026-10-14 11:31:30 +08:00
const callListRef = useRef < HTMLDivElement >( null );
2026-10-09 13:12:52 +08:00
const notify = useCallback (( message : string , kind : Notice [ "kind" ] = "success" ) => setNotice ({ message , kind }), []);
2026-10-09 15:55:46 +08:00
const commit = useCallback (( next : Dataset | (( current : Dataset ) => Dataset )) => {
const current = historyRef . current ;
const updated = commitEdit ( current , typeof next === "function" ? next ( current . present ) : next );
if ( updated === current ) return ;
historyRef . current = updated ; setHistory ( updated );
2026-10-09 13:12:52 +08:00
versionRef . current ++ ;
2026-10-09 15:55:46 +08:00
setDirty ( true ); setSavedTime ( null );
}, []);
const updateAnnotation = useCallback (( id : string , patch : Partial < Annotation >) => commit ( current => {
const row = current . annotations . find ( item => item . id === id );
if ( ! row || Object . entries ( patch ). every (([ key , value ]) => row [ key as keyof Annotation ] === value )) return current ;
return { ... current , annotations : current.annotations.map ( item => item . id === id ? { ... item , ... patch } : item ) };
}), [ commit ]);
2026-10-09 13:12:52 +08:00
const loadWorkspace = useCallback ( async () => {
try {
const response = await fetch ( "/api/workspace" , { cache : "no-store" }); const payload = await response . json ();
if ( ! response . ok ) throw new Error ( payload . error || "工作区读取失败" );
const workspace = payload as Workspace ; let dataset : Dataset = { transcripts : workspace.transcripts , annotations : workspace.annotations }; let restored = false ;
try {
const draft = JSON . parse ( localStorage . getItem ( DRAFT_KEY ) || "null" );
if ( draft && ( ! workspace . savedAt || draft . updatedAt > Date . parse ( workspace . savedAt ))) {
parseTranscripts ( exportTranscripts ( draft . transcripts )); parseAnnotations ( exportAnnotations ( draft . annotations ));
dataset = { transcripts : draft.transcripts , annotations : draft.annotations }; restored = true ;
}
} catch { /* A corrupt browser draft never blocks opening the project. */ }
originalGroupsRef . current = new Map ( workspace . recordings . map ( row => [ row . call_id , recordingDataset ( row )]));
2026-10-09 15:55:46 +08:00
const initialHistory = createEditHistory ( dataset ); historyRef . current = initialHistory ; setHistory ( initialHistory ); setRecordings ( workspace . recordings );
2026-10-09 13:12:52 +08:00
const preferredRecording = workspace . recordings . find ( row => recordingDataset ( row ) === DEFAULT_DATASET );
const initialCall = preferredRecording ? . call_id || dataset . annotations [ 0 ] ? . call_id || dataset . transcripts [ 0 ] ? . call_id || workspace . recordings [ 0 ] ? . call_id || "demo" ;
const initial = dataset . annotations . filter ( row => row . call_id === initialCall ). sort (( a , b ) => a . decision_ms - b . decision_ms )[ 0 ];
setDatasetScope ( preferredRecording ? DEFAULT_DATASET : ALL_DATASETS );
setCallId ( initialCall ); setSelectedId ( initial ? . id || null ); setCurrentMs ( initial ? . decision_ms || 0 );
setDirty ( restored ); setSavedTime ( workspace . savedAt || null ); setInitialized ( true );
if ( restored ) notify ( "已恢复这个浏览器中尚未保存的草稿" , "info" );
} catch ( error ) { notify ( error instanceof Error ? error . message : "工作区读取失败" , "error" ); }
finally { setLoading ( false ); }
}, [ notify ]);
useEffect (() => { void loadWorkspace (); }, [ loadWorkspace ]);
2026-10-14 11:31:30 +08:00
useEffect (() => { callListRef . current ? . querySelector ( ".call-item.selected" ) ? . scrollIntoView ({ block : "nearest" }); }, [ callId ]);
2026-10-09 13:12:52 +08:00
useEffect (() => {
if ( ! initialized || ! dirty ) return ;
const timer = setTimeout (() => { try { localStorage . setItem ( DRAFT_KEY , JSON . stringify ({ transcripts , annotations , updatedAt : Date.now () })); } catch { notify ( "浏览器草稿空间不足,请保存到项目或导出 CSV" , "error" ); } }, 350 );
return () => clearTimeout ( timer );
}, [ transcripts , annotations , initialized , dirty , notify ]);
useEffect (() => { if ( ! notice ) return ; const timer = setTimeout (() => setNotice ( null ), notice . kind === "error" ? 9000 : 4500 ); return () => clearTimeout ( timer ); }, [ notice ]);
useEffect (() => { const urls = objectUrls . current ; return () => urls . forEach ( url => URL . revokeObjectURL ( url )); }, []);
useEffect (() => {
2026-10-09 15:55:46 +08:00
if ( ! helpOpen ) return ;
2026-10-09 13:12:52 +08:00
const oldFocus = document . activeElement as HTMLElement | null ;
const modal = document . querySelector < HTMLElement >( ".modal" ); const focusables = () => [...( modal ? . querySelectorAll < HTMLElement >( 'button:not(:disabled),input,select,textarea' ) || [])];
( modal ? . querySelector < HTMLElement >( "[autofocus]" ) || focusables ()[ 0 ]) ? . focus ();
const handler = ( event : KeyboardEvent ) => {
2026-10-09 15:55:46 +08:00
if ( event . key === "Escape" ) setHelpOpen ( false );
2026-10-09 13:12:52 +08:00
if ( event . key === "Tab" ) { const elements = focusables (); const first = elements [ 0 ], last = elements . at ( - 1 ); if ( event . shiftKey && document . activeElement === first ) { event . preventDefault (); last ? . focus (); } else if ( ! event . shiftKey && document . activeElement === last ) { event . preventDefault (); first ? . focus (); } }
};
document . addEventListener ( "keydown" , handler ); return () => { document . removeEventListener ( "keydown" , handler ); oldFocus ? . focus (); };
2026-10-09 15:55:46 +08:00
}, [ helpOpen ]);
2026-10-09 13:12:52 +08:00
const callAnnotations = useMemo (() => annotations . filter ( row => row . call_id === callId ). sort (( a , b ) => a . decision_ms - b . decision_ms ), [ annotations , callId ]);
const callTranscripts = useMemo (() => transcripts . map (( row , index ) => ({ row , index })). filter (({ row }) => row . call_id === callId ). sort (( a , b ) => a . row . start_ms - b . row . start_ms ), [ transcripts , callId ]);
const selected = callAnnotations . find ( row => row . id === selectedId ) || null ;
const recording = recordings . find ( row => row . call_id === callId ) || null ;
const durationMs = audioDuration || Math . max ( 10000 , ... callTranscripts . map (({ row }) => row . end_ms + 1500 ), ... callAnnotations . map ( row => row . decision_ms + 1500 ));
const selectedError = selected ? validateAnnotation ( selected , annotations . filter ( row => row . id !== selected . id )) : null ;
const allCalls = useMemo (() => {
const groupNumbers = new Map < string , number >();
return [... new Set ([... recordings . map ( row => row . call_id ), ... transcripts . map ( row => row . call_id ), ... annotations . map ( row => row . call_id )])]. map ( id => {
const recording = recordings . find ( row => row . call_id === id ); const rows = annotations . filter ( row => row . call_id === id );
const group = callDataset ( id , recordings , originalGroupsRef . current );
const groupNumber = ( groupNumbers . get ( group ) || 0 ) + 1 ; groupNumbers . set ( group , groupNumber );
return { id , group , name : id === "demo" ? "示例对话" : recording ? ` ${ group } · ${ String ( groupNumber ). padStart ( 2 , "0" ) } ` : id , count : rows.length , pending : rows.filter ( row => row . can_reply_now === "" ). length , recording };
});
}, [ transcripts , annotations , recordings ]);
const datasetGroups = useMemo (() => [... new Set ( allCalls . map ( row => row . group ))]. sort (( a , b ) => a === DEFAULT_DATASET ? - 1 : b === DEFAULT_DATASET ? 1 : a.localeCompare ( b , "zh-CN" )), [ allCalls ]);
const scopedCalls = allCalls . filter ( row => datasetScope === ALL_DATASETS || row . group === datasetScope );
const scopedCallIds = new Set ( scopedCalls . map ( row => row . id ));
const scopedAnnotations = annotations . filter ( row => scopedCallIds . has ( row . call_id ));
const visibleCalls = scopedCalls . filter ( row => ` ${ row . id } ${ row . name } ` . toLowerCase (). includes ( search . toLowerCase ()) && ( filter !== "pending" || row . pending > 0 || row . count === 0 ));
const callName = allCalls . find ( row => row . id === callId ) ? . name || callId ;
const completed = scopedAnnotations . filter ( row => row . can_reply_now !== "" ). length ;
const seek = useCallback (( ms : number ) => {
const next = Math . max ( 0 , Math . min ( durationMs , Math . round ( ms ))); setCurrentMs ( next ); playBoundary . current = null ;
if ( audioRef . current && recording && Number . isFinite ( audioRef . current . duration )) audioRef . current . currentTime = next / 1000 ;
}, [ durationMs , recording ]);
2026-10-09 15:55:46 +08:00
const selectAnnotation = useCallback (( id : string , seekToPoint = true ) => { const row = annotations . find ( row => row . id === id ); if ( row ) { setTranscriptDraft ( null ); setSelectedId ( id ); if ( seekToPoint ) seek ( row . decision_ms ); } }, [ annotations , seek ]);
2026-10-09 13:12:52 +08:00
const chooseCall = ( id : string ) => {
if ( id === callId ) return ;
audioRef . current ? . pause (); playBoundary . current = null ; setPlaying ( false ); setAudioDuration ( 0 ); setWaveform ( null );
const initial = annotations . filter ( row => row . call_id === id ). sort (( a , b ) => a . decision_ms - b . decision_ms )[ 0 ];
2026-10-09 15:55:46 +08:00
setCallId ( id ); setSelectedId ( initial ? . id || null ); setCurrentMs ( initial ? . decision_ms || 0 ); setTranscriptDraft ( null );
2026-10-09 13:12:52 +08:00
};
const changeDatasetScope = ( scope : string ) => {
setDatasetScope ( scope ); setSearch ( "" ); setFilter ( "all" );
const first = allCalls . find ( row => scope === ALL_DATASETS || row . group === scope );
if ( first ) chooseCall ( first . id );
};
const togglePlay = useCallback ( async () => {
const audio = audioRef . current ; if ( ! recording || ! audio ) { notify ( "请先为这通对话关联录音" , "info" ); return ; } playBoundary . current = null ;
if ( audio . paused ) { try { if ( audio . ended ) audio . currentTime = 0 ; await audio . play (); } catch { notify ( "录音暂时无法播放,请重新关联文件" , "error" ); } } else audio . pause ();
}, [ recording , notify ]);
const review = async ( future : boolean ) => {
if ( ! selected || ! recording || ! audioRef . current ) { notify ( "关联录音后即可回听这个判断点" , "info" ); return ; }
const audio = audioRef . current ; const start = future ? selected.decision_ms : Math.max ( 0 , selected . decision_ms - 4000 );
audio . currentTime = Math . min ( start , durationMs ) / 1000 ; setCurrentMs ( start ); playBoundary . current = future ? Math . min ( durationMs , start + 4000 ) : selected . decision_ms ;
2026-10-09 15:55:46 +08:00
try { await audio . play (); } catch { notify ( "录音暂时无法播放" , "error" ); }
2026-10-09 13:12:52 +08:00
};
useEffect (() => {
if ( ! playing ) return ; let frame = 0 ;
const tick = () => { const audio = audioRef . current ; if ( audio ) { const position = audio . currentTime * 1000 ; if ( playBoundary . current !== null && position >= playBoundary . current ) { const stop = playBoundary . current ; audio . pause (); audio . currentTime = stop / 1000 ; setCurrentMs ( stop ); playBoundary . current = null ; return ; } setCurrentMs ( Math . round ( position )); } frame = requestAnimationFrame ( tick ); };
frame = requestAnimationFrame ( tick ); return () => cancelAnimationFrame ( frame );
}, [ playing ]);
useEffect (() => {
const audio = audioRef . current ;
const sameSource = !! recording && audio ? . currentSrc === new URL ( recording . url , window . location . href ). href ;
const knownDuration = sameSource && audio && Number . isFinite ( audio . duration ) && audio . duration > 0 ? Math . round ( audio . duration * 1000 ) : 0 ;
setWaveform ( null ); setAudioDuration ( knownDuration ); setPlaying ( false ); if ( ! recording ) return ;
const controller = new AbortController (); let context : AudioContext | null = null ;
void ( async () => {
try { const response = await fetch ( recording . url , { signal : controller.signal }); if ( ! response . ok ) throw new Error ( "无法读取录音" ); const bytes = await response . arrayBuffer (); context = new AudioContext (); const buffer = await context . decodeAudioData ( bytes ); if ( controller . signal . aborted ) return ;
2026-10-09 15:55:46 +08:00
const count = 3200 ;
const channels = Array . from ({ length : buffer.numberOfChannels }, ( _ , channel ) => {
const samples = buffer . getChannelData ( channel );
return Array . from ({ length : count }, ( _ , index ) => {
const begin = Math . floor ( index * samples . length / count ), end = Math . floor (( index + 1 ) * samples . length / count );
let peak = 0 ;
for ( let i = begin ; i < end ; i ++ ) peak = Math . max ( peak , Math . abs ( samples [ i ]));
return peak ;
});
});
// Share the scale across channels so their relative levels remain visible.
const max = channels . reduce (( largest , peaks ) => Math . max ( largest , ... peaks ), . 01 );
setWaveform ( channels . map ( peaks => peaks . map ( peak => peak / max )));
2026-10-09 13:12:52 +08:00
} catch ( error ) { if ( ! controller . signal . aborted ) notify ( error instanceof Error ? error . message : "波形读取失败" , "error" ); } finally { if ( context ) void context . close (); }
})(); return () => controller . abort ();
}, [ recording ? . url , notify ]);
useEffect (() => { if ( audioRef . current ) audioRef . current . playbackRate = rate ; }, [ rate , recording ]);
2026-10-09 15:55:46 +08:00
const addAnnotationAt = useCallback (( ms : number ) => {
if ( mode === "view" || loading ) return ;
setTranscriptDraft ( null ); setMenu ( null );
const time = Math . max ( 0 , Math . min ( durationMs , Math . round ( ms )));
const existing = callAnnotations . find ( row => row . decision_ms === time );
if ( existing ) { setSelectedId ( existing . id ); seek ( time ); notify ( "这个时间点已有标注,已为你选中" , "info" ); return ; }
const row = createAnnotation ( callId , time );
const lastUser = callTranscripts . map ( item => item . row ). filter ( item => item . role === "user" && item . end_ms <= time ). at ( - 1 );
row . user_text_at_decision = lastUser ? . transcript || "" ;
commit ({ transcripts , annotations : [... annotations , row ] }); setSelectedId ( row . id ); seek ( time ); notify ( `已在 ${ formatTime ( time ) } 新增判断点` );
}, [ mode , loading , durationMs , callAnnotations , callId , callTranscripts , transcripts , annotations , commit , notify , seek ]);
const moveAnnotation = ( id : string , ms : number ) => {
if ( mode !== "edit" ) return ;
const time = Math . max ( 0 , Math . min ( durationMs , Math . round ( ms )));
const row = callAnnotations . find ( item => item . id === id );
if ( ! row || row . decision_ms === time ) return ;
if ( callAnnotations . some ( item => item . id !== id && item . decision_ms === time )) { notify ( "这个时间点已有标注,请选择另一个位置" , "info" ); return ; }
updateAnnotation ( id , { decision_ms : time });
};
const addAnnotation = useCallback (() => addAnnotationAt ( currentMs ), [ addAnnotationAt , currentMs ]);
2026-10-09 13:12:52 +08:00
const setLabel = useCallback (( label : Annotation [ "can_reply_now" ]) => {
if ( ! selected || mode === "view" ) return ; const existing = reasonOptions . find ( row => row . value === selected . reason && row . can_reply_now === label );
updateAnnotation ( selected . id , { can_reply_now : label , reason : existing?.value || ( label === "1" ? "completed_turn" : label === "0" ? "mid_turn_pause" : "uncertain" ) });
}, [ selected , mode , updateAnnotation ]);
2026-10-09 15:55:46 +08:00
const travelHistory = useCallback (( direction : "undo" | "redo" ) => {
if ( mode !== "edit" || loading ) return ;
const current = historyRef . current ;
const updated = direction === "undo" ? undoEdit ( current ) : redoEdit ( current );
if ( updated === current ) return ;
historyRef . current = updated ; setHistory ( updated );
const previous = updated . present ;
versionRef . current ++ ; setDirty ( true ); setSavedTime ( null );
setTranscriptDraft ( draft => draft && draft . index >= 0 && previous . transcripts [ draft . index ]
? { index : draft.index , row : { ... previous . transcripts [ draft . index ] } } : null );
2026-10-09 13:12:52 +08:00
const ids = new Set ([... previous . annotations . map ( row => row . call_id ), ... previous . transcripts . map ( row => row . call_id ), ... recordings . map ( row => row . call_id )]);
const firstInScope = [... ids ]. find ( id => datasetScope === ALL_DATASETS || callDataset ( id , recordings , originalGroupsRef . current ) === datasetScope );
const nextCall = ids . has ( callId ) ? callId : firstInScope || previous . annotations [ 0 ] ? . call_id || previous . transcripts [ 0 ] ? . call_id || recordings [ 0 ] ? . call_id || "demo" ;
const nextSelection = previous . annotations . find ( row => row . id === selectedId && row . call_id === nextCall ) || previous . annotations . find ( row => row . call_id === nextCall );
2026-10-09 15:55:46 +08:00
setSelectedId ( nextSelection ? . id || null );
2026-10-09 13:12:52 +08:00
if ( nextCall !== callId ) {
const nextGroup = callDataset ( nextCall , recordings , originalGroupsRef . current );
if ( datasetScope !== ALL_DATASETS && datasetScope !== nextGroup ) setDatasetScope ( nextGroup );
setSearch ( "" ); setFilter ( "all" ); audioRef . current ? . pause (); setCallId ( nextCall ); setCurrentMs ( nextSelection ? . decision_ms || 0 ); setAudioDuration ( 0 );
}
2026-10-09 15:55:46 +08:00
notify ( direction === "undo" ? "已撤销上一步修改" : "已重做上一步修改" , "info" );
}, [ mode , loading , selectedId , callId , recordings , datasetScope , notify ]);
const undo = useCallback (() => travelHistory ( "undo" ), [ travelHistory ]);
const redo = useCallback (() => travelHistory ( "redo" ), [ travelHistory ]);
useEffect (() => {
const handler = ( event : KeyboardEvent ) => {
if ( event . defaultPrevented || event . isComposing || helpOpen || mode !== "edit" || loading || event . altKey || ! ( event . ctrlKey || event . metaKey )) return ;
const target = event . target ;
if ( target instanceof HTMLElement && ( target . closest ( "input, textarea, select" ) || target . isContentEditable )) return ;
const key = event . key . toLowerCase ();
if ( key !== "z" && key !== "y" ) return ;
event . preventDefault ();
if ( key === "y" || event . shiftKey ) redo (); else undo ();
};
// Capture only history shortcuts so timeline controls cannot swallow them.
window . addEventListener ( "keydown" , handler , true );
return () => window . removeEventListener ( "keydown" , handler , true );
}, [ undo , redo , mode , loading , helpOpen ]);
2026-10-09 13:12:52 +08:00
const saveWorkspace = useCallback ( async () => {
if ( saving ) return ; const version = versionRef . current ;
try { parseTranscripts ( exportTranscripts ( transcripts )); parseAnnotations ( exportAnnotations ( annotations )); if ( audioDuration && callAnnotations . some ( row => row . decision_ms > audioDuration )) throw new Error ( "当前判断点超出录音时长,请调整后保存" ); setSaving ( true );
const response = await fetch ( "/api/workspace" , { method : "POST" , headers : { "Content-Type" : "application/json" }, body : JSON.stringify ({ transcripts , annotations }) }); const payload = await response . json (); if ( ! response . ok ) throw new Error ( payload . error || "保存失败" );
setSavedTime ( payload . savedAt ); if ( versionRef . current === version ) { setDirty ( false ); localStorage . removeItem ( DRAFT_KEY ); } notify ( versionRef . current === version ? "已保存对话转写和 EOT 标注到项目" : "已保存,保存期间的新修改仍保留为草稿" );
} catch ( error ) { notify ( error instanceof Error ? error . message : "保存失败" , "error" ); } finally { setSaving ( false ); }
}, [ saving , transcripts , annotations , audioDuration , callAnnotations , notify ]);
useEffect (() => {
const handler = ( event : KeyboardEvent ) => {
2026-10-09 15:55:46 +08:00
if ( event . defaultPrevented || helpOpen ) return ;
2026-10-09 13:12:52 +08:00
if (( event . ctrlKey || event . metaKey ) && event . key . toLowerCase () === "s" ) { event . preventDefault (); void saveWorkspace (); return ; }
const target = event . target as HTMLElement ; if ( target . closest ( "input, textarea, select, [contenteditable=true]" ) || event . ctrlKey || event . metaKey || event . altKey ) return ;
if ( event . code === "Space" ) { if ( target . closest ( "button, a, summary" )) return ; event . preventDefault (); void togglePlay (); } else if ( event . key === "ArrowLeft" ) { event . preventDefault (); seek ( currentMs - 1000 ); } else if ( event . key === "ArrowRight" ) { event . preventDefault (); seek ( currentMs + 1000 ); }
else if ( event . key === "1" ) setLabel ( "1" ); else if ( event . key === "2" ) setLabel ( "0" ); else if ( event . key === "3" ) setLabel ( "" ); else if ( event . key . toLowerCase () === "a" ) addAnnotation ();
else if ( event . key === "[" || event . key === "]" ) { const index = callAnnotations . findIndex ( row => row . id === selectedId ); const next = callAnnotations [ index + ( event . key === "[" ? - 1 : 1 )]; if ( next ) selectAnnotation ( next . id ); }
}; window . addEventListener ( "keydown" , handler ); return () => window . removeEventListener ( "keydown" , handler );
}, [ currentMs , togglePlay , seek , setLabel , addAnnotation , selectedId , callAnnotations , selectAnnotation , saveWorkspace , helpOpen , transcriptDraft ]);
const importCsv = async ( event : ChangeEvent < HTMLInputElement >, kind : "asr" | "eot" ) => {
const file = event . target . files ? .[ 0 ]; event . target . value = "" ; if ( ! file ) return ;
try { const content = await file . text (); const rows = kind === "asr" ? parseTranscripts ( content ) : parseAnnotations ( content ); commit ( kind === "asr" ? { transcripts : rows as Transcript [], annotations } : { transcripts , annotations : rows as Annotation [] });
const initialCall = rows [ 0 ] ? . call_id ; if ( initialCall ) {
const nextGroup = callDataset ( initialCall , recordings , originalGroupsRef . current );
if ( datasetScope !== ALL_DATASETS && datasetScope !== nextGroup ) setDatasetScope ( nextGroup );
setSearch ( "" ); setFilter ( "all" ); audioRef . current ? . pause ();
if ( initialCall === callId && audioRef . current ) audioRef . current . currentTime = 0 ;
if ( initialCall !== callId ) setAudioDuration ( 0 );
setCallId ( initialCall ); setCurrentMs ( 0 ); setTranscriptDraft ( null ); setSelectedId ( kind === "eot" ? ( rows [ 0 ] as Annotation ). id : annotations.find ( row => row . call_id === initialCall ) ? . id || null );
2026-10-09 15:55:46 +08:00
} notify ( `已导入 ${ rows . length } 条 ${ kind === "asr" ? "对话转写" : "EOT 标注" } ,可撤销或保存到项目` );
2026-10-09 13:12:52 +08:00
} catch ( error ) { notify ( error instanceof Error ? error . message : "导入失败" , "error" ); }
};
const attachAudio = ( event : ChangeEvent < HTMLInputElement >) => { const file = event . target . files ? .[ 0 ]; event . target . value = "" ; if ( ! file ) return ; const url = URL . createObjectURL ( file ); objectUrls . current . add ( url ); setRecordings ( rows => [... rows . filter ( row => row . call_id !== callId ), { call_id : callId , name : file.name , path : file.name , url }]); notify ( `已关联录音: ${ file . name } 。文件关联保留在当前页面` , "info" ); };
const exportCsv = ( kind : "asr" | "eot" , scope : "current" | "all" ) => {
try {
const rows = kind === "asr" ? transcripts : annotations ;
const exportRows = scope === "current" ? rows . filter ( row => scopedCallIds . has ( row . call_id )) : rows ;
const content = kind === "asr" ? exportTranscripts ( exportRows as Transcript []) : exportAnnotations ( exportRows as Annotation []);
if ( kind === "asr" ) parseTranscripts ( content ); else parseAnnotations ( content );
download ( kind === "asr" ? "asr_history.csv" : "eot_labels.csv" , content ); setMenu ( null );
notify ( `已导出 ${ scope === "current" ? datasetScope : "全部数据" } 的 ${ exportRows . length } 条 ${ kind === "asr" ? "对话转写" : "EOT 标注" } ` );
} catch ( error ) { notify ( error instanceof Error ? error . message : "导出失败" , "error" ); }
};
2026-10-09 15:55:46 +08:00
const addTranscriptAt = ( role : Transcript [ "role" ]) => {
if ( loading || mode === "view" ) return ;
setMenu ( null ); audioRef . current ? . pause ();
const start = Math . max ( 0 , Math . min ( Math . round ( currentMs ), durationMs - 1 ));
setTranscriptDraft ({ index : - 1 , row : { call_id : callId , role , start_ms : start , end_ms : Math.min ( durationMs , start + 1000 ), transcript : "" } });
};
const resizeTranscript = ( index : number , startMs : number , endMs : number ) => {
if ( mode === "view" || ! transcripts [ index ]) return ;
if ( startMs < 0 || endMs > durationMs || endMs <= startMs ) { notify ( "片段时间必须在录音内,且结束时间晚于开始时间" , "error" ); return ; }
const next = transcripts . map (( row , i ) => i === index ? { ... row , start_ms : startMs , end_ms : endMs } : row );
commit ({ transcripts : next , annotations });
setTranscriptDraft ( draft => draft ? . index === index ? { ... draft , row : { ... draft . row , start_ms : startMs , end_ms : endMs } } : draft );
};
const saveTranscript = () => {
if ( ! transcriptDraft || mode === "view" ) return ;
try {
const next = [... transcripts ];
if ( transcriptDraft . row . end_ms > durationMs ) throw new Error ( "片段结束时间不能超过录音时长" );
if ( transcriptDraft . index === - 1 ) next . push ( transcriptDraft . row ); else next [ transcriptDraft . index ] = transcriptDraft . row ;
parseTranscripts ( exportTranscripts ( next )); commit ({ transcripts : next , annotations });
setTranscriptDraft ({ ... transcriptDraft , index : transcriptDraft.index === - 1 ? next . length - 1 : transcriptDraft.index });
notify ( "对话片段已更新" );
} catch ( error ) { notify ( error instanceof Error ? error . message : "片段无效" , "error" ); }
};
2026-10-09 13:12:52 +08:00
return < div className = "app-shell" >
< input ref = { asrImportRef } type = "file" accept = ".csv,text/csv" hidden onChange = { event => void importCsv ( event , "asr" )} />
< input ref = { eotImportRef } type = "file" accept = ".csv,text/csv" hidden onChange = { event => void importCsv ( event , "eot" )} />
< input ref = { audioImportRef } type = "file" accept = "audio/*,.mp3,.wav,.m4a,.ogg,.flac,.webm" hidden onChange = { attachAudio } />
< audio ref = { audioRef } src = { recording ? . url } preload = "metadata" onPlay = {() => setPlaying ( true )} onPause = {() => setPlaying ( false )} onEnded = {() => setPlaying ( false )} onLoadedMetadata = {() => { const audio = audioRef . current ; if ( audio && Number . isFinite ( audio . duration )) { setAudioDuration ( Math . round ( audio . duration * 1000 )); audio . currentTime = Math . min ( currentMs / 1000 , audio . duration ); audio . playbackRate = rate ; } }} onTimeUpdate = {() => { if ( ! playing && audioRef . current ) setCurrentMs ( Math . round ( audioRef . current . currentTime * 1000 )); }} onError = {() => { if ( recording ) notify ( "录音无法播放,请重新关联文件" , "error" ); }} />
< header className = "topbar" >< a className = "brand" href = "/" aria-label = "EOT Studio 首页" >< span className = "brand-mark" >< Icon name = "wave" size = { 22 } /></ span >< span > EOT < span className = "brand-light" > Studio </ span >< small > 话轮标注工作台 </ small ></ span ></ a >< div className = "workspace-tag" >< span className = "status-dot" /> 本地工作空间 </ div >< div className = "top-actions" >
< span className = "save-status" >< span className = { `status-dot ${ dirty ? "orange" : "" } ` } />{ saving ? "正在保存…" : dirty ? "草稿自动保留" : savedTime ? "已保存到项目" : "未保存到项目" }</ span >
< div className = "menu-wrap" >< button className = "button ghost" onClick = {() => setMenu ( menu === "import" ? null : "import" )} aria-expanded = { menu === "import" }>< Icon name = "upload" /> 导入 < Icon name = "chevron" size = { 14 } /></ button >{ menu === "import" && < div className = "dropdown" >< button onClick = {() => { asrImportRef . current ? . click (); setMenu ( null ); }}>< Icon name = "file" /> 对话转写 CSV </ button >< button onClick = {() => { eotImportRef . current ? . click (); setMenu ( null ); }}>< Icon name = "file" /> EOT 标注 CSV </ button >< button onClick = {() => { audioImportRef . current ? . click (); setMenu ( null ); }}>< Icon name = "headphones" /> 为当前通话关联录音 </ button ></ div >}</ div >
< div className = "menu-wrap" >< button className = "button ghost" onClick = {() => setMenu ( menu === "export" ? null : "export" )} aria-expanded = { menu === "export" }>< Icon name = "download" /> 导出 </ button >{ menu === "export" && < div className = "dropdown" >{ datasetScope !== ALL_DATASETS && <>< button onClick = {() => exportCsv ( "eot" , "current" )}>< Icon name = "file" /> 当前数据集 EOT 标注 CSV </ button >< button onClick = {() => exportCsv ( "asr" , "current" )}>< Icon name = "file" /> 当前数据集对话转写 CSV </ button >< div className = "dropdown-divider" /></>}< button onClick = {() => exportCsv ( "eot" , "all" )}>< Icon name = "file" /> 全部 EOT 标注 CSV </ button >< button onClick = {() => exportCsv ( "asr" , "all" )}>< Icon name = "file" /> 全部对话转写 CSV </ button ></ div >}</ div >
< button className = "button primary" onClick = {() => void saveWorkspace ()} disabled = { saving || loading }>< Icon name = "save" />{ saving ? "保存中…" : "保存到项目" }</ button >
</ div ></ header >
< div className = "workspace-grid" >< aside className = "sidebar" >< div className = "sidebar-heading" >< span className = "eyebrow" > 对话库 </ span >< span className = "count-badge" >{ scopedCalls . length }</ span ></ div >< label className = "dataset-scope" >< span > 数据集 </ span >< select aria-label = "选择数据集" value = { datasetScope } disabled = { loading } onChange = { event => changeDatasetScope ( event . target . value )}>{ datasetGroups . map ( group => < option key = { group } value = { group }>{ group }</ option >)}< option value = { ALL_DATASETS }> 全部数据 </ option ></ select >< small > 导出可选当前数据集,项目保存保留全部 </ small ></ label >< label className = "search-box" >< Icon name = "search" size = { 16 } />< input aria-label = "搜索通话" value = { search } onChange = { event => setSearch ( event . target . value )} placeholder = "搜索名称或通话 ID" /></ label >< div className = "sidebar-tabs" >< button className = { filter === "all" ? "active" : "" } onClick = {() => setFilter ( "all" )}> 全部对话 </ button >< button className = { filter === "pending" ? "active" : "" } onClick = {() => setFilter ( "pending" )}> 待处理 </ button ></ div >
2026-10-14 11:31:30 +08:00
< div className = "call-list" ref = { callListRef }>{ loading ? < p className = "muted loading-label" > 正在读取工作空间… </ p > : visibleCalls . length ? visibleCalls . map ( row => < button key = { row . id } className = { `call-item ${ callId === row . id ? "selected" : "" } ` } onClick = {() => chooseCall ( row . id )} title = { row . id }>< span className = "call-icon" >< Icon name = { row . recording ? "headphones" : "file" } size = { 17 } /></ span >< span className = "call-item-copy" >< strong >{ row . name }</ strong >< small >{ row . id === "demo" ? "标准示例 · 可自由编辑" : row . id . slice ( 0 , 18 ) + ( row . id . length > 18 ? "…" : "" )}</ small >< span >{ row . count } 个判断点 { row . pending > 0 && < em > · { row . pending } 待处理 </ em >}</ span ></ span >{ callId === row . id && < span className = "selected-dot" />}</ button >) : < p className = "muted loading-label" > 没有找到对话 </ p >}</ div >
2026-10-09 13:12:52 +08:00
< div className = "sidebar-bottom" >< div >< span > 标注进度 </ span >< strong >{ completed }< span > / { scopedAnnotations . length }</ span ></ strong ></ div >< div className = "progress-track" >< span style = {{ width : ` ${ scopedAnnotations . length ? completed / scopedAnnotations . length * 100 : 0 } %` }} /></ div >< p > 每次判断,留出合适的接话空间。 </ p >< button className = "help-link" onClick = {() => setHelpOpen ( true )}>< Icon name = "help" size = { 16 } /> 标注说明与快捷键 </ button ></ div >
</ aside >
< main className = "main-workspace" >< div className = "page-heading" >< div >< div className = "breadcrumb" > 工作空间 < span > / </ span > { callName }</ div >< h1 > 对话标注 < span className = "offline-badge" > 离线转写 </ span ></ h1 >< p title = { callId }>{ callId === "demo" ? "从一段对话开始,判断用户是否已经说完。" : callId }</ p ></ div >< div className = "view-switch" aria-label = "工作模式" >< button className = { mode === "edit" ? "active" : "" } onClick = {() => setMode ( "edit" )}>< Icon name = "edit" size = { 15 } /> 标注 </ button >< button className = { mode === "view" ? "active" : "" } onClick = {() => { setMode ( "view" ); setTranscriptDraft ( null ); }}> 查看 </ button ></ div ></ div >
< section className = "audio-card panel" >< div className = "panel-heading" >< div >< h2 >< Icon name = "headphones" /> 录音与时间轴 </ h2 >< p className = "audio-filename" title = { recording ? . name }>{ recording ? . name || "示例尚未关联录音,可先查看转写与标注" }</ p ></ div >< button className = "button small ghost" onClick = {() => audioImportRef . current ? . click ()}>< Icon name = "upload" size = { 15 } />{ recording ? "更换录音" : "关联录音" }</ button ></ div >
2026-10-09 15:55:46 +08:00
< div className = "player" >< div className = "player-main" >< button className = "icon-button" aria-label = "后退 3 秒" onClick = {() => seek ( currentMs - 3000 )}>< Icon name = "back" /></ button >< button className = "play-button" aria-label = { playing ? "暂停录音" : "播放录音" } onClick = {() => void togglePlay ()} disabled = { ! recording }>< Icon name = { playing ? "pause" : "play" } size = { 19 } /></ button >< button className = "icon-button" aria-label = "前进 3 秒" onClick = {() => seek ( currentMs + 3000 )}>< Icon name = "forward" /></ button >< span className = "time-display" >{ formatTime ( currentMs )}< span > / { formatTime ( durationMs )}</ span ></ span ></ div >< div className = "player-secondary" >< label className = "speed-label" > 播放速度 < select aria-label = "播放速度" value = { rate } onChange = { event => setRate ( Number ( event . target . value ))}>{[. 75 , 1 , 1.25 , 1.5 , 2 ]. map ( value => < option key = { value } value = { value }>{ value } × </ option >)}</ select ></ label >{ mode === "edit" && < div className = "menu-wrap" >< button className = "button small subtle" onClick = {() => setMenu ( menu === "create" ? null : "create" )} disabled = { loading } aria-expanded = { menu === "create" } aria-haspopup = "menu" >< Icon name = "plus" size = { 16 } /> 在播放位置新增 < Icon name = "chevron" size = { 13 } /></ button >{ menu === "create" && < div className = "dropdown" role = "menu" aria-label = "新增项目" >< button role = "menuitem" onClick = { addAnnotation }> EOT 判断点 </ button >< button role = "menuitem" onClick = {() => addTranscriptAt ( "agent" )}> 机器人转写 </ button >< button role = "menuitem" onClick = {() => addTranscriptAt ( "user" )}> 用户转写 </ button ></ div >}</ div >}</ div ></ div >
2026-10-14 10:51:29 +08:00
< Timeline callId = { callId } recordingPath = { recording ? . url . startsWith ( "/api/audio?" ) ? recording.path : null } durationMs = { durationMs } currentMs = { currentMs } decisionMs = { selected ? . decision_ms ?? null } transcripts = { callTranscripts . map (({ row , index }) => ({ ... row , index }))} annotations = { callAnnotations } selectedId = { selectedId } waveform = { waveform } onSeek = { seek } onMoveAnnotation = { moveAnnotation } onSelectAnnotation = { selectAnnotation } onSelectTranscript = { index => {
2026-10-09 15:55:46 +08:00
const row = transcripts [ index ]; seek ( row . start_ms );
audioRef . current ? . pause (); setTranscriptDraft ({ index , row : { ... row } });
}} onResizeTranscript = { resizeTranscript } editingTranscriptIndex = { transcriptDraft ? . index ?? null } onAddAnnotation = { addAnnotationAt } readOnly = { mode === "view" } />
< div className = "timeline-legend" >< span >< i className = "legend-dot agent" /> 机器人 </ span >< span >< i className = "legend-dot user" /> 用户 </ span >< span >< i className = "legend-line" /> 播放位置 </ span >< span >< i className = "legend-dot decision" /> EOT 判断点 </ span >< span className = "timeline-tip" >{ mode === "edit" ? "拖动片段边界 · 点击块在右侧编辑" : "点击时间轴或判断点查看" }</ span ></ div >
2026-10-09 13:12:52 +08:00
</ section >
2026-10-09 15:55:46 +08:00
< div className = "workspace-footer" >< span >< Icon name = "check" size = { 14 } /> 转写与标注使用同一录音时间轴 </ span >< div className = "history-actions" >< button onClick = { undo } disabled = { ! history . past . length || mode === "view" || loading } title = "撤销上一步(Ctrl / ⌘ + Z) " aria-keyshortcuts = "Control+Z Meta+Z" >< Icon name = "undo" size = { 14 } /> 撤销上一步 </ button >< button onClick = { redo } disabled = { ! history . future . length || mode === "view" || loading } title = "重做上一步(Ctrl + Y / ⌘ + Shift + Z) " aria-keyshortcuts = "Control+Y Meta+Y Control+Shift+Z Meta+Shift+Z" >< Icon name = "redo" size = { 14 } /> 重做上一步 </ button ></ div ></ div >
2026-10-09 13:12:52 +08:00
</ main >
2026-10-09 15:55:46 +08:00
{ transcriptDraft ? < TranscriptEditor draft = { transcriptDraft } readOnly = { mode === "view" }
onChange = { row => setTranscriptDraft ({ ... transcriptDraft , row })} onSave = { saveTranscript } onClose = {() => setTranscriptDraft ( null )}
onDelete = {() => { commit ({ transcripts : transcripts.filter (( _ , index ) => index !== transcriptDraft . index ), annotations }); setTranscriptDraft ( null ); notify ( "已删除片段,可撤销" , "info" ); }}
/> : < aside className = "annotation-sidebar" >< div className = "editor-heading" >< div >< span className = "eyebrow" >{ mode === "view" ? "标注详情" : "当前标注" }</ span >< h2 > 这个位置,可以接话吗? </ h2 ></ div >< span className = "editor-number" >{ selected ? String ( callAnnotations . findIndex ( row => row . id === selected . id ) + 1 ). padStart ( 2 , "0" ) : "—" }</ span ></ div >
2026-10-09 13:12:52 +08:00
{ selected ? < div className = "editor-body" >< div className = "decision-card" >< span >< i className = "legend-dot decision" /> 判断时间 </ span >< strong >{ formatTime ( selected . decision_ms )}</ strong >< div className = "review-actions" >< button disabled = { ! recording } onClick = {() => void review ( false )}>< Icon name = "back" size = { 14 } /> 听判断前 </ button >< button disabled = { ! recording } onClick = {() => void review ( true )}>< Icon name = "forward" size = { 14 } /> 听后续 </ button ></ div ></ div >
< fieldset className = "label-fieldset" disabled = { mode === "view" }>< legend > 用户是否已完成当前表达? </ legend >{([{ value : "1" , title : "可以接话" , detail : "当前表达已完成" , icon : "check" , number : "1" , cls : "positive" }, { value : "0" , title : "继续等待" , detail : "现在接话会打断用户" , icon : "clock" , number : "2" , cls : "negative" }, { value : "" , title : "需要仲裁" , detail : "证据不足,暂不确定" , icon : "help" , number : "3" , cls : "pending" }] as const ). map ( choice => < button key = { choice . number } className = { `label-choice ${ choice . cls } ${ selected . can_reply_now === choice . value ? "active" : "" } ` } aria-pressed = { selected . can_reply_now === choice . value } onClick = {() => setLabel ( choice . value )}>< span className = "choice-symbol" >< Icon name = { choice . icon } size = { 18 } /></ span >< span >< strong >{ choice . title }</ strong >< small >{ choice . detail }</ small ></ span >< kbd >{ choice . number }</ kbd ></ button >)}</ fieldset >
< label className = "form-field" >< span > 判断时的用户文本 < small > 截至当前橙色判断点 </ small ></ span >< textarea aria-label = "判断时的用户文本" value = { selected . user_text_at_decision } placeholder = "例如:我明天下午…" readOnly = { mode === "view" } onChange = { event => updateAnnotation ( selected . id , { user_text_at_decision : event.target.value })} rows = { 3 } />< small > 只填写当时已说出的内容,可保留未完成的表达。 </ small ></ label >
< label className = "form-field" >< span > 判断原因 </ span >< select aria-label = "判断原因" disabled = { mode === "view" } value = { selected . reason } onChange = { event => updateAnnotation ( selected . id , { reason : event.target.value })}>< option value = "" > 请选择原因 </ option >{ reasonOptions . filter ( row => row . can_reply_now === selected . can_reply_now ). map ( row => < option key = { row . value } value = { row . value }>{ row . label }</ option >)}</ select ></ label >
< div className = "form-two" >< label className = "form-field" >< span > 标注把握 </ span >< select aria-label = "标注把握" disabled = { mode === "view" } value = { selected . confidence } onChange = { event => updateAnnotation ( selected . id , { confidence : event.target.value as Annotation [ "confidence" ] })}>< option value = "" > 未填写 </ option >< option value = "high" > 高 </ option >< option value = "medium" > 中 </ option >< option value = "low" > 低 </ option ></ select ></ label >< label className = "form-field" >< span > 标注员 </ span >< input aria-label = "标注员" readOnly = { mode === "view" } placeholder = "姓名或 ID" value = { selected . annotator } onChange = { event => updateAnnotation ( selected . id , { annotator : event.target.value })} /></ label ></ div >
{ selectedError && < p className = "field-error" role = "alert" >{ selectedError }</ p >}< div className = "editor-bottom" >< span >< span className = { `mini-dot ${ labelClass ( selected ) } ` } />{ labelName ( selected )}</ span >{ mode === "edit" && < button className = "delete-button" onClick = {() => { commit ({ transcripts , annotations : annotations.filter ( row => row . id !== selected . id ) }); setSelectedId ( callAnnotations . find ( row => row . id !== selected . id ) ? . id || null ); notify ( "已删除判断点,可撤销" , "info" ); }}>< Icon name = "trash" size = { 15 } /> 删除 </ button >}</ div >
</ div > : < div className = "empty-editor" >< span className = "empty-marker" >< Icon name = "plus" size = { 26 } /></ span >< h3 > 创建第一个判断点 </ h3 >< p > 在时间轴上定位,再新增标注,记录此刻是否适合接话。 </ p >{ mode === "edit" && < button className = "button primary" onClick = { addAnnotation } disabled = { loading }>< Icon name = "plus" /> 新增判断点 </ button >}</ div >}
2026-10-09 15:55:46 +08:00
</ aside >}</ div >
2026-10-09 13:12:52 +08:00
< footer className = "keyboard-footer" >< span > EOT Studio < i /> 让每一次接话更自然 </ span >< div >< span >< kbd > 空格 </ kbd > 播放 / 暂停 </ span >< span >< kbd > 1 </ kbd >< kbd > 2 </ kbd >< kbd > 3 </ kbd > 标签 </ span >< span >< kbd >[</ kbd >< kbd >]</ kbd > 切换判断点 </ span >< button onClick = {() => setHelpOpen ( true )} aria-label = "查看快捷键" >< Icon name = "help" size = { 15 } /></ button ></ div ></ footer >
{ notice && < div className = { `toast ${ notice . kind } ` } role = { notice . kind === "error" ? "alert" : "status" }>< Icon name = { notice . kind === "success" ? "check" : "help" } />< span >{ notice . message }</ span >< button aria-label = "关闭消息" onClick = {() => setNotice ( null )}>< Icon name = "close" size = { 15 } /></ button ></ div >}
{ menu && < button className = "menu-scrim" aria-label = "关闭菜单" onClick = {() => setMenu ( null )} />}
2026-10-09 15:55:46 +08:00
{ helpOpen && < div className = "modal-backdrop" onClick = {() => setHelpOpen ( false )}>< section className = "modal help-modal" role = "dialog" aria-modal = "true" aria-labelledby = "help-title" onClick = { event => event . stopPropagation ()}>< div className = "modal-heading" >< h2 id = "help-title" > 把判断留在正确的时刻 </ h2 >< button className = "icon-button" aria-label = "关闭说明" onClick = {() => setHelpOpen ( false )}>< Icon name = "close" /></ button ></ div >< p > 橙色标记表示 EOT 判断时间,黑色细线表示播放位置。听判断之前的录音,再回答“机器人现在接话是否合适”。 </ p >< ol >< li >< strong > 可以接话: </ strong > 用户已完成当前表达,或已回答机器人问题。 </ li >< li >< strong > 继续等待: </ strong > 用户只是停顿、犹豫或表达未完成。 </ li >< li >< strong > 需要仲裁: </ strong > 录音或上下文不足,保留空标签。 </ li ></ ol >< p > 当前用户文本只写到橙色判断点。时间轴展示完整对话用于校对,后续转写不会自动写入当前文本。 </ p >< div className = "shortcut-grid" >< span >< kbd > 空格 </ kbd > 播放 / 暂停 </ span >< span >< kbd > A </ kbd > 新增判断点 </ span >< span >< kbd > ← </ kbd >< kbd > → </ kbd > 播放位置前后 1 秒 </ span >< span >< kbd > 1 </ kbd >< kbd > 2 </ kbd >< kbd > 3 </ kbd > 选择标签 </ span >< span >< kbd >[</ kbd >< kbd >]</ kbd > 上 / 下一个判断点 </ span >< span >< kbd > ⌘ / Ctrl + S </ kbd > 保存到项目 </ span >< span >< kbd > ⌘ / Ctrl + Z </ kbd > 撤销上一步 </ span >< span >< kbd > Ctrl + Y </ kbd > 重做上一步 </ span ></ div >< p className = "muted" > 最近 40 步修改可撤销和重做;撤销后作新修改会清空重做记录。文本输入框内保留原生文字撤销。 Mac 也支持 ⌘ + Shift + Z 重做。刷新页面会清空撤销记录。编辑会保留为浏览器草稿。“保存到项目”写入工作数据目录;“导出”下载标准 CSV 。上传录音的关联在页面关闭后需重新建立。 </ p >< button className = "button primary" onClick = {() => setHelpOpen ( false )}> 开始标注 </ button ></ section ></ div >}
2026-10-09 13:12:52 +08:00
</ div >;
}