Files
xiaobailing-turn-detection/labels/eot-studio/app/workbench.tsx
T

390 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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" ? "标准示例" : "已导入对话";
}
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<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",
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 <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() {
const [history, setHistory] = useState(() => createEditHistory<Dataset>({ transcripts: [], annotations: [] }));
const historyRef = useRef(history);
const { transcripts, annotations } = history.present;
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);
const [waveform, setWaveform] = useState<number[][] | null>(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<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");
const [menu, setMenu] = useState<"import" | "export" | "create" | null>(null);
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>());
const callListRef = useRef<HTMLDivElement>(null);
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<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]);
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(() => { callListRef.current?.querySelector(".call-item.selected")?.scrollIntoView({ block: "nearest" }); }, [callId]);
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<HTMLElement>(".modal"); const focusables = () => [...(modal?.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,textarea') || [])];
(modal?.querySelector<HTMLElement>("[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<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]);
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<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);
} notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`);
} 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"); }
};
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 <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>
<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>
<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>
<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>
<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 => {
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>
</section>
<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>
</main>
{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>
{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>}
</aside>}</div>
<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)} />}
{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>}
</div>;
}