完善韩泰 EOT 标注编辑器并更新标注规范与项目资料
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import Timeline from "@/components/timeline";
|
||||
import CallReferencePanel from "@/components/call-reference";
|
||||
import type { CallReference } from "@/lib/call-reference";
|
||||
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[]; callReferences: CallReference[]; source: "saved" | "example"; savedAt?: string };
|
||||
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 = "韩泰机器人抢话";
|
||||
@@ -23,13 +23,13 @@ function callDataset(id: string, recordings: Recording[], originalGroups?: Map<s
|
||||
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" | "headphones" | "chevron" | "close" | "file";
|
||||
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", 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",
|
||||
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 ? "待仲裁" : "待标注"; }
|
||||
@@ -37,15 +37,15 @@ function labelClass(row: Annotation) { return row.can_reply_now === "1" ? "posit
|
||||
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 [transcripts, setTranscripts] = useState<Transcript[]>([]);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [history, setHistory] = useState(() => createEditHistory<Dataset>({ transcripts: [], annotations: [] }));
|
||||
const historyRef = useRef(history);
|
||||
const { transcripts, annotations } = history.present;
|
||||
const [recordings, setRecordings] = useState<Recording[]>([]);
|
||||
const [callReferences, setCallReferences] = useState<CallReference[]>([]);
|
||||
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 [waveform, setWaveform] = useState<number[][] | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [rate, setRate] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -57,10 +57,8 @@ export default function Workbench() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [datasetScope, setDatasetScope] = useState(DEFAULT_DATASET);
|
||||
const [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [showFuture, setShowFuture] = useState(false);
|
||||
const [menu, setMenu] = useState<"import" | "export" | null>(null);
|
||||
const [menu, setMenu] = useState<"import" | "export" | "create" | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [undoStack, setUndoStack] = useState<Dataset[]>([]);
|
||||
const [transcriptDraft, setTranscriptDraft] = useState<{ index: number; row: Transcript } | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
@@ -73,12 +71,19 @@ export default function Workbench() {
|
||||
const originalGroupsRef = useRef(new Map<string, string>());
|
||||
|
||||
const notify = useCallback((message: string, kind: Notice["kind"] = "success") => setNotice({ message, kind }), []);
|
||||
const commit = useCallback((next: Dataset) => {
|
||||
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++;
|
||||
setUndoStack(stack => [...stack.slice(-39), { transcripts, annotations }]);
|
||||
setTranscripts(next.transcripts); setAnnotations(next.annotations); setDirty(true); setSavedTime(null);
|
||||
}, [annotations, transcripts]);
|
||||
const updateAnnotation = useCallback((id: string, patch: Partial<Annotation>) => commit({ transcripts, annotations: annotations.map(row => row.id === id ? { ...row, ...patch } : row) }), [annotations, transcripts, commit]);
|
||||
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();
|
||||
@@ -92,7 +97,7 @@ export default function Workbench() {
|
||||
}
|
||||
} catch { /* A corrupt browser draft never blocks opening the project. */ }
|
||||
originalGroupsRef.current = new Map(workspace.recordings.map(row => [row.call_id, recordingDataset(row)]));
|
||||
setTranscripts(dataset.transcripts); setAnnotations(dataset.annotations); setRecordings(workspace.recordings); setCallReferences(workspace.callReferences || []);
|
||||
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];
|
||||
@@ -112,23 +117,22 @@ export default function Workbench() {
|
||||
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 (!transcriptDraft && !helpOpen) return;
|
||||
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") { setTranscriptDraft(null); setHelpOpen(false); }
|
||||
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(); };
|
||||
}, [!!transcriptDraft, helpOpen]);
|
||||
}, [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 boundaryMs = selected?.decision_ms ?? currentMs;
|
||||
const selectedError = selected ? validateAnnotation(selected, annotations.filter(row => row.id !== selected.id)) : null;
|
||||
const allCalls = useMemo(() => {
|
||||
const groupNumbers = new Map<string, number>();
|
||||
@@ -146,19 +150,17 @@ export default function Workbench() {
|
||||
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 visibleTranscripts = callTranscripts.filter(({ row }) => showFuture || row.end_ms <= boundaryMs);
|
||||
const hiddenCount = callTranscripts.length - visibleTranscripts.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) => { const row = annotations.find(row => row.id === id); if (row) { setSelectedId(id); seek(row.decision_ms); setShowFuture(false); } }, [annotations, seek]);
|
||||
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); setShowFuture(false); setTranscriptDraft(null);
|
||||
setCallId(id); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0); setTranscriptDraft(null);
|
||||
};
|
||||
const changeDatasetScope = (scope: string) => {
|
||||
setDatasetScope(scope); setSearch(""); setFilter("all");
|
||||
@@ -173,7 +175,7 @@ export default function Workbench() {
|
||||
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;
|
||||
if (future) setShowFuture(true); try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); }
|
||||
try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); }
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!playing) return; let frame = 0;
|
||||
@@ -188,37 +190,86 @@ export default function Workbench() {
|
||||
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 samples = buffer.getChannelData(0), count = 640; const peaks = 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; });
|
||||
const max = Math.max(.01, ...peaks); setWaveform(peaks.map(peak => peak / max));
|
||||
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 addAnnotation = useCallback(() => {
|
||||
if (mode === "view" || loading) return; const time = Math.round(currentMs); const existing = callAnnotations.find(row => row.decision_ms === time);
|
||||
if (existing) { notify("这个时间点已有标注,已为你选中", "info"); setSelectedId(existing.id); return; }
|
||||
const row = createAnnotation(callId, time); commit({ transcripts, annotations: [...annotations, row] }); setSelectedId(row.id); setShowFuture(false); notify(`已在 ${formatTime(time)} 新增判断点`);
|
||||
}, [mode, loading, currentMs, callAnnotations, callId, transcripts, annotations, commit, notify]);
|
||||
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 undo = useCallback(() => {
|
||||
const previous = undoStack.at(-1); if (!previous) return;
|
||||
versionRef.current++; setTranscripts(previous.transcripts); setAnnotations(previous.annotations); setUndoStack(stack => stack.slice(0, -1)); setDirty(true);
|
||||
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); setShowFuture(false);
|
||||
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("已撤销上一步修改", "info");
|
||||
}, [undoStack, selectedId, callId, recordings, datasetScope, notify]);
|
||||
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);
|
||||
@@ -228,7 +279,7 @@ export default function Workbench() {
|
||||
}, [saving, transcripts, annotations, audioDuration, callAnnotations, notify]);
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || helpOpen || transcriptDraft) return;
|
||||
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); }
|
||||
@@ -246,7 +297,7 @@ export default function Workbench() {
|
||||
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);
|
||||
} setShowFuture(false); notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`);
|
||||
} 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"); };
|
||||
@@ -260,7 +311,30 @@ export default function Workbench() {
|
||||
notify(`已导出${scope === "current" ? datasetScope : "全部数据"}的 ${exportRows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"}`);
|
||||
} catch (error) { notify(error instanceof Error ? error.message : "导出失败", "error"); }
|
||||
};
|
||||
const saveTranscript = () => { if (!transcriptDraft) return; try { const next = [...transcripts]; if (transcriptDraft.index === -1) next.push(transcriptDraft.row); else next[transcriptDraft.index] = transcriptDraft.row; parseTranscripts(exportTranscripts(next)); commit({ transcripts: next, annotations }); setTranscriptDraft(null); notify("对话片段已更新"); } 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")} />
|
||||
@@ -282,25 +356,21 @@ export default function Workbench() {
|
||||
|
||||
<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" && <button className="button small subtle" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} />在播放位置新增</button>}</div></div>
|
||||
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row }) => ({ ...row, transcript: showFuture || row.end_ms <= boundaryMs ? row.transcript : row.start_ms <= boundaryMs ? "跨越判断点的片段" : "后续片段" }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onDecisionChange={ms => {
|
||||
if (!selected || mode !== "edit") return;
|
||||
const time = Math.max(0, Math.min(durationMs, Math.round(ms)));
|
||||
if (callAnnotations.some(row => row.id !== selected.id && row.decision_ms === time)) { notify("这个时间点已有标注,请选择另一个位置", "info"); return; }
|
||||
updateAnnotation(selected.id, { decision_ms: time });
|
||||
}} onSelectAnnotation={selectAnnotation} 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>
|
||||
<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 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>
|
||||
<CallReferencePanel references={callReferences.filter(row => row.call_id === callId)} />
|
||||
<section className="transcript-panel panel"><div className="panel-heading"><div><h2>对话转写<span className="count-badge">{callTranscripts.length}</span></h2><p>当前展示截至 <strong>{formatTime(boundaryMs)}</strong> 的对话历史</p></div><div className="transcript-actions"><label className="toggle-label"><input type="checkbox" checked={showFuture} onChange={event => setShowFuture(event.target.checked)} /><span className="toggle-track" />显示后续</label>{mode === "edit" && <button className="icon-button" aria-label="新增对话片段" onClick={() => { const start = Math.max(0, Math.min(currentMs, durationMs - 1)); setTranscriptDraft({ index: -1, row: { call_id: callId, role: "user", start_ms: start, end_ms: Math.min(durationMs, start + 1000), transcript: "" } }); }}><Icon name="plus" /></button>}</div></div>
|
||||
{showFuture && <div className="arbitration-note"><Icon name="help" size={15} />后续对话仅供辅助仲裁,不进入当前判断点的模型输入。</div>}
|
||||
<div className="transcript-list">{visibleTranscripts.length ? visibleTranscripts.map(({ row, index }) => <div key={index} className={`transcript-row ${row.role} ${row.end_ms > boundaryMs ? "future" : ""} ${currentMs >= row.start_ms && currentMs <= row.end_ms ? "playing-row" : ""}`}><div className={`speaker-avatar ${row.role}`}>{row.role === "agent" ? "机" : "人"}</div><div className="transcript-content"><div className="speaker-meta"><strong>{row.role === "agent" ? "机器人" : "用户"}</strong><button className="transcript-time" onClick={() => seek(row.start_ms)}>{formatTime(row.start_ms)} — {formatTime(row.end_ms)}</button>{row.end_ms > boundaryMs && <span className="future-tag">判断之后</span>}</div><p>{row.transcript || <span className="muted">(暂无转写)</span>}</p></div>{mode === "edit" && <div className="row-tools"><button className="icon-button" aria-label={`编辑 ${formatTime(row.start_ms)} 对话片段`} onClick={() => setTranscriptDraft({ index, row: { ...row } })}><Icon name="edit" size={15} /></button>{row.role === "user" && row.end_ms <= boundaryMs && selected && <button className="text-insert" title="将这段文本填入当前标注" onClick={() => updateAnnotation(selected.id, { user_text_at_decision: row.transcript })}>填入</button>}</div>}</div>) : <div className="empty-transcript"><Icon name="file" size={28} /><h3>这里还没有判断前的转写</h3><p>导入对话转写 CSV,或添加并校对语音片段。</p>{mode === "edit" && <button className="button small ghost" onClick={() => asrImportRef.current?.click()}>导入转写</button>}</div>}</div>
|
||||
{!showFuture && hiddenCount > 0 && <button className="hidden-history" onClick={() => setShowFuture(true)}><Icon name="clock" size={15} />{hiddenCount} 个后续或跨越判断点的片段已隐藏<span>展开辅助仲裁 →</span></button>}
|
||||
{selected && <div className="current-text-preview"><span>当前用户文本</span><p>{selected.user_text_at_decision || "尚未填写,请按判断时刻的录音核对。"}</p><small>{formatTime(selected.decision_ms)} 时已经说出的内容</small></div>}
|
||||
</section><div className="workspace-footer"><span><Icon name="check" size={14} />转写与标注使用同一录音时间轴</span><button onClick={undo} disabled={!undoStack.length || mode === "view"}><Icon name="undo" size={14} />撤销上一步</button></div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
@@ -308,12 +378,10 @@ export default function Workbench() {
|
||||
<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>}
|
||||
<div className="annotation-list-section"><div className="list-heading"><h3>本通话判断点<span>{callAnnotations.length}</span></h3>{mode === "edit" && <button className="icon-button" aria-label="新增判断点" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} /></button>}</div><div className="annotation-list">{callAnnotations.map(row => <button key={row.id} className={`annotation-item ${row.id === selectedId ? "selected" : ""}`} onClick={() => selectAnnotation(row.id)}><span className={`mini-dot ${labelClass(row)}`} /><div><strong>{formatTime(row.decision_ms)}</strong><small>{row.user_text_at_decision || "尚未填写用户文本"}</small></div><span className={`label-pill ${labelClass(row)}`}>{labelName(row)}</span></button>)}{!callAnnotations.length && <p className="muted">暂无判断点</p>}</div></div>
|
||||
</aside></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)} />}
|
||||
{transcriptDraft && <div className="modal-backdrop" onClick={() => setTranscriptDraft(null)}><section className="modal" role="dialog" aria-modal="true" aria-labelledby="transcript-dialog-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="transcript-dialog-title">{transcriptDraft.index === -1 ? "新增" : "编辑"}对话片段</h2><button className="icon-button" aria-label="关闭片段编辑" onClick={() => setTranscriptDraft(null)}><Icon name="close" /></button></div><label className="form-field"><span>说话人</span><select value={transcriptDraft.row.role} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, role: event.target.value as Transcript["role"] } })}><option value="user">用户</option><option value="agent">机器人</option></select></label><div className="form-two"><label className="form-field"><span>开始时间(毫秒)</span><input type="number" min="0" value={transcriptDraft.row.start_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, start_ms: Number(event.target.value) } })} /></label><label className="form-field"><span>结束时间(毫秒)</span><input type="number" min="1" value={transcriptDraft.row.end_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, end_ms: Number(event.target.value) } })} /></label></div><label className="form-field"><span>转写内容</span><textarea rows={4} value={transcriptDraft.row.transcript} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, transcript: event.target.value } })} autoFocus /></label><div className="modal-actions">{transcriptDraft.index >= 0 && <button className="delete-button" onClick={() => { commit({ transcripts: transcripts.filter((_, index) => index !== transcriptDraft.index), annotations }); setTranscriptDraft(null); notify("已删除片段,可撤销", "info"); }}><Icon name="trash" />删除片段</button>}<button className="button ghost" onClick={() => setTranscriptDraft(null)}>取消</button><button className="button primary" onClick={saveTranscript}>保存片段</button></div></section></div>}
|
||||
{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></div><p className="muted">编辑会保留为浏览器草稿。“保存到项目”写入工作数据目录;“导出”下载标准 CSV。上传录音的关联在页面关闭后需重新建立。</p><button className="button primary" onClick={() => setHelpOpen(false)}>开始标注</button></section></div>}
|
||||
{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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user