320 lines
47 KiB
TypeScript
320 lines
47 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
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 { 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 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" | "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",
|
|||
|
|
};
|
|||
|
|
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 [transcripts, setTranscripts] = useState<Transcript[]>([]);
|
|||
|
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|||
|
|
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 [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 [showFuture, setShowFuture] = useState(false);
|
|||
|
|
const [menu, setMenu] = useState<"import" | "export" | 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);
|
|||
|
|
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 notify = useCallback((message: string, kind: Notice["kind"] = "success") => setNotice({ message, kind }), []);
|
|||
|
|
const commit = useCallback((next: Dataset) => {
|
|||
|
|
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]);
|
|||
|
|
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)]));
|
|||
|
|
setTranscripts(dataset.transcripts); setAnnotations(dataset.annotations); setRecordings(workspace.recordings); setCallReferences(workspace.callReferences || []);
|
|||
|
|
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 (!transcriptDraft && !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 === "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]);
|
|||
|
|
|
|||
|
|
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>();
|
|||
|
|
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 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 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);
|
|||
|
|
};
|
|||
|
|
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;
|
|||
|
|
if (future) setShowFuture(true); 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 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));
|
|||
|
|
} 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 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 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);
|
|||
|
|
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]);
|
|||
|
|
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 || transcriptDraft) 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);
|
|||
|
|
} setShowFuture(false); 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 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"); } };
|
|||
|
|
|
|||
|
|
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">{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" && <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>
|
|||
|
|
</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>
|
|||
|
|
</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>
|
|||
|
|
{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>}
|
|||
|
|
<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>
|
|||
|
|
<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>}
|
|||
|
|
</div>;
|
|||
|
|
}
|