"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import Timeline from "@/components/timeline"; import TranscriptEditor from "@/components/transcript-editor"; import { createEditHistory, commitEdit, undoEdit, redoEdit } from "@/lib/edit-history"; 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[] }; type Workspace = Dataset & { recordings: Recording[]; source: "saved" | "draft" | "example"; savedAt?: string }; 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) { const original = originalGroups?.get(id); if (original) return original; const recording = recordings.find(row => row.call_id === id); return recording ? recordingDataset(recording) : id === "demo" ? "标准示例" : "已导入对话"; } type IconName = "wave" | "upload" | "download" | "save" | "search" | "play" | "pause" | "back" | "forward" | "plus" | "check" | "clock" | "help" | "edit" | "trash" | "undo" | "redo" | "headphones" | "chevron" | "close" | "file"; const paths: Record = { 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", 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", }; function Icon({ name, size = 18 }: { name: IconName; size?: number }) { return ; } 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() { const [history, setHistory] = useState(() => createEditHistory({ transcripts: [], annotations: [] })); const historyRef = useRef(history); const { transcripts, annotations } = history.present; const [recordings, setRecordings] = useState([]); const [callId, setCallId] = useState("demo"); const [selectedId, setSelectedId] = useState(null); const [currentMs, setCurrentMs] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [waveform, setWaveform] = useState(null); 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(null); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const [datasetScope, setDatasetScope] = useState(DEFAULT_DATASET); const [mode, setMode] = useState<"edit" | "view">("edit"); const [menu, setMenu] = useState<"import" | "export" | "create" | null>(null); const [notice, setNotice] = useState(null); const [transcriptDraft, setTranscriptDraft] = useState<{ index: number; row: Transcript } | null>(null); const [helpOpen, setHelpOpen] = useState(false); const audioRef = useRef(null); const asrImportRef = useRef(null); const eotImportRef = useRef(null); const audioImportRef = useRef(null); const playBoundary = useRef(null); const objectUrls = useRef(new Set()); const versionRef = useRef(0); const originalGroupsRef = useRef(new Map()); const notify = useCallback((message: string, kind: Notice["kind"] = "success") => setNotice({ message, kind }), []); 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); versionRef.current++; setDirty(true); setSavedTime(null); }, []); const updateAnnotation = useCallback((id: string, patch: Partial) => 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]); 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)])); const initialHistory = createEditHistory(dataset); historyRef.current = initialHistory; setHistory(initialHistory); setRecordings(workspace.recordings); 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]); 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(() => { if (!helpOpen) return; const oldFocus = document.activeElement as HTMLElement | null; const modal = document.querySelector(".modal"); const focusables = () => [...(modal?.querySelectorAll('button:not(:disabled),input,select,textarea') || [])]; (modal?.querySelector("[autofocus]") || focusables()[0])?.focus(); const handler = (event: KeyboardEvent) => { if (event.key === "Escape") setHelpOpen(false); 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(); }; }, [helpOpen]); 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(); 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]); 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]); 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]; setCallId(id); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0); setTranscriptDraft(null); }; 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; try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); } }; 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; 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))); } 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]); 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]); 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]); 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); 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); setSelectedId(nextSelection?.id || null); 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); } 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]); 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) => { if (event.defaultPrevented || helpOpen) return; 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, 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); } notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`); } catch (error) { notify(error instanceof Error ? error.message : "导入失败", "error"); } }; const attachAudio = (event: ChangeEvent) => { 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"); } }; 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"); } }; return
void importCsv(event, "asr")} /> void importCsv(event, "eot")} />