完善韩泰 EOT 标注编辑器并更新标注规范与项目资料

This commit is contained in:
Xin Wang
2026-10-09 15:55:46 +08:00
parent 138b08a8e4
commit 0204f1288a
29 changed files with 1340 additions and 1271 deletions
+9 -9
View File
@@ -14,15 +14,15 @@ npm run dev
## 使用
1. 默认聚焦“韩泰机器人抢话”的 6 通录音。左侧数据集选择器可切换到其他目录、标准示例或全部数据,通话数量与标注进度按当前数据集统计。分别导入 ASR 和 EOT CSV,或直接选择项目 `data` 目录中的通话录音开始整理。
2. 选择通话和录音,在时间轴查看机器人、用户语音段及 EOT 判断标记。播放、拖动时间轴或选择标记定位需要核对的位置。
3. 编辑 `decision_ms`、当前用户文本、是否可以接话、判断原因、置信度和标注员。音频播放器的时间采用整通录音的媒体时钟。
4. 对话历史默认只显示 `end_ms <= decision_ms` 的记录。当前段横跨判断位置时,请听音频填写 `user_text_at_decision`;不能按完整转写的字符长度推算前半句。查看后续对话用于仲裁,后续内容不进入该判断位置的模型输入。
5. 点击“保存到项目”将两份 CSV 写到 `labels/EOT标注/工作数据/`。导出 CSV 会下载当前数据,可交给其他标注员或用于训练和评测。
1. 默认载入“韩泰机器人抢话”的 6 通录音,以及原 XLSX 转换出的 76 条转写、39 个 EOT 判断点。已有工作数据时优先读取工作数据,初稿保留在 `labels/EOT标注/韩泰初稿/`。
2. 左右声道分别显示波形,共用时间刻度。机器人、用户转写作为对应轨道上的块显示;点击块在右侧栏编辑说话人、文本和起止时间;拖动左右边界直接调整片段范围。拖动结束作为一次修改,可撤销。查看模式的字段为只读。点击“在播放位置新增”的下拉菜单,选择机器人转写、用户转写或 EOT 判断点。
3. 点击判断点轨道空白处直接新增 EOT;聚焦该轨道后按 Enter 可在播放位置新增。点击已有标记选择标签,直接拖动判断点轴上的标记调整时间,聚焦标记后可用方向键微调;拖动时吸附转写边界、播放位置和其他判断点,按住 Alt 暂停吸附,松开后作为一次修改可撤销。同一通话的判断点不能重合。放大时间轴便于查看短片段。顶部只保留播放头。播放头在点击或拖动定位时,会吸附附近的转写块边界及 EOT 判断点;按住 Alt 暂停吸附。
4. 右侧编辑判断时的用户文本、是否可以接话、判断原因、置信度和标注员。标注员可选,不影响处理状态。标签为“可以接话”或“继续等待”就计入已处理,只有空白标签(需要仲裁)算待处理。
5. 时间轴下方“撤销上一步 / 重做上一步”支持最近 40 步转写、判断点和导入修改,Ctrl+Z 撤销,Ctrl+Y 或 Ctrl+Shift+Z 重做;Mac 也支持 ⌘+Z / ⌘+Shift+Z。在时间轴的块或判断点上聚焦时同样有效,文本输入框内保留原生文字撤销。撤销后作新修改会清空重做记录;刷新页面后历史记录清空,浏览器草稿仍按原规则恢复。
5. 时间轴显示整段对话,模型输入仍仅使用 `end_ms <= decision_ms` 的完整历史;当前用户表达跨越判断点时需回听填写 `user_text_at_decision`,不按字符比例推算。
6. 点击“保存到项目”写入 `labels/EOT标注/工作数据/`。切换数据集只改变浏览范围;导出菜单可选当前数据集或全部,项目保存保留全部数据。
草稿在当前浏览器的本地存储中保存,刷新后可恢复。录音来自项目目录或本次选择的本地文件;本地文件的播放地址不会存入草稿。浏览器草稿与项目 CSV 分开,只有明确点击“保存到项目”才会改写项目中的工作数据。原始示例和 `data` 下的录音不会被修改。
切换数据集只改变浏览范围;导出菜单可选择“当前数据集”或“全部”,项目保存会保留完整数据。韩泰录音旁可展开“原表校对参考”,查看已校对表提取的 39 条原始片段。它们没有起止时间或 EOT 判断时间,因此需回听定位后填写标准转写和标签。原表第 5 列没有表头,提取结果按原值展示;原表结论不会自动转为 EOT 标签。参考数据在 `labels/eot-studio/data/hantai-reference.json` 中,保留源文件、工作表和行号,不会随 CSV 保存被改写。当前工具没有工作 CSV 时,仍载入标准示例供切换查看。
浏览器草稿自动保留,刷新后可恢复。网页保存不覆盖原 XLSX、初稿和录音。初稿转换方法、标签映射与限制见 [转换说明](../EOT标注/韩泰初稿/转换说明.md),原行号及原结论保留在初稿目录的 `source_rows.json` 中。原表没有时间信息,录音中未收录的开场与其他发言需人工补充。
## CSV 格式
@@ -47,7 +47,7 @@ demo,5200,我明天下午,0,high,mid_turn_pause,A
## 本地接口
- `GET /api/workspace`:读取工作数据、项目录音列表和只读 `callReferences` 原表参考。工作数据的两份 CSV 都存在时使用它们,否则载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `GET /api/workspace`:读取工作数据和项目录音列表。工作数据的两份 CSV 都存在时使用它们,否则载入韩泰初稿;初稿不存在时载入示例;已保存 CSV 格式错误时返回错误,不用示例覆盖。
- `POST /api/workspace`:接受 `{ transcripts, annotations }`,校验后保存到固定工作数据目录,并返回 `savedAt`。同一进程的读写按顺序执行;文件锁防止不同进程同时操作,请求完成后释放。若进程在保存中被强制终止,请确认没有其他服务运行,检查残留 `.backup` 文件并恢复同一次保存前的两份 CSV,再移除工作数据目录的 `.workspace.lock`。
- `GET /api/audio?path=...`:仅提供 `data` 内的 MP3、WAV、M4A、OGG、FLAC 和 WebM 录音,支持 Range 请求和 HEAD,便于跳转播放。路径相对 `data` 目录,不能通过 `..` 或符号链接访问目录之外的文件。
+7 -39
View File
@@ -10,7 +10,6 @@ import {
type Transcript,
} from "@/lib/annotations";
import { isSameOriginRequest } from "@/lib/request-origin";
import type { CallReference } from "@/lib/call-reference";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -20,7 +19,7 @@ const dataRoot = path.join(projectRoot, "data");
const labelsRoot = path.join(projectRoot, "labels", "EOT标注");
const savedRoot = path.join(labelsRoot, "工作数据");
const exampleRoot = path.join(labelsRoot, "示例");
const callReferencesPath = path.join(process.cwd(), "data", "hantai-reference.json");
const draftRoot = path.join(labelsRoot, "韩泰初稿");
const lockPath = path.join(savedRoot, ".workspace.lock");
const extensions = new Set([".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"]);
const maxPayloadBytes = 20 * 1024 * 1024;
@@ -119,39 +118,6 @@ async function discoverRecordings(): Promise<Recording[]> {
return result;
}
async function readCallReferences(): Promise<CallReference[]> {
let source: string;
try {
source = await fs.readFile(callReferencesPath, "utf8");
} catch (error) {
if (hasCode(error, "ENOENT")) return [];
throw error;
}
try {
const references: unknown = JSON.parse(source);
if (!Array.isArray(references)) throw new Error("顶层必须为数组。");
return references.map((value, index) => {
const message = `第 ${index + 1} 条原表记录格式无效。`;
if (!value || typeof value !== "object") throw new Error(message);
const row = value as Partial<CallReference>;
if (typeof row.call_id !== "string" || !row.call_id.trim()
|| !row.source || typeof row.source.file !== "string" || typeof row.source.sheet !== "string"
|| !Number.isSafeInteger(row.source.row) || row.source.row < 1
|| !Array.isArray(row.fields)
|| row.fields.some((field) => !field || typeof field.label !== "string" || typeof field.value !== "string")) {
throw new Error(message);
}
return {
call_id: row.call_id,
source: { file: row.source.file, sheet: row.source.sheet, row: row.source.row },
fields: row.fields.map((field) => ({ label: field.label, value: field.value })),
};
});
} catch (error) {
throw new WorkspaceError(`原表参考数据格式有误:${error instanceof Error ? error.message : "JSON 格式错误。"}`);
}
}
async function readCsvPair(directory: string): Promise<{ transcripts: Transcript[]; annotations: Annotation[] }> {
const [transcriptsCsv, annotationsCsv] = await Promise.all([
fs.readFile(path.join(directory, "asr_history.csv"), "utf8"),
@@ -179,22 +145,24 @@ export async function GET(): Promise<Response> {
if (await exists(lockPath)) {
throw new WorkspaceError("工作数据正在保存,请稍后重试。", 409);
}
const [hasTranscripts, hasAnnotations, recordings, callReferences] = await Promise.all([
const [hasTranscripts, hasAnnotations, recordings] = await Promise.all([
exists(path.join(savedRoot, "asr_history.csv")),
exists(path.join(savedRoot, "eot_labels.csv")),
discoverRecordings(),
readCallReferences(),
]);
if (hasTranscripts && hasAnnotations) {
return await withDiskLock(async () => json({
...await readCsvPair(savedRoot),
recordings,
callReferences,
source: "saved",
savedAt: await savedAt(),
}));
}
return json({ ...await readCsvPair(exampleRoot), recordings, callReferences, source: "example" });
if (await exists(path.join(draftRoot, "asr_history.csv")) && await exists(path.join(draftRoot, "eot_labels.csv"))) {
const stats = await Promise.all([fs.stat(path.join(draftRoot, "asr_history.csv")), fs.stat(path.join(draftRoot, "eot_labels.csv"))]);
return json({ ...await readCsvPair(draftRoot), recordings, source: "draft", savedAt: new Date(Math.max(...stats.map(stat => stat.mtimeMs))).toISOString() });
}
return json({ ...await readCsvPair(exampleRoot), recordings, source: "example" });
});
} catch (error) {
return errorResponse(error);
+13 -22
View File
@@ -198,7 +198,8 @@ button.transcript-time:hover { color: #6c8455; text-decoration: underline; }
.workspace-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; color: #748167; font-size: 11px; }
.workspace-footer > span { display: flex; align-items: center; gap: 5px; }
.workspace-footer svg { width: 12px; height: 12px; }
.workspace-footer > button { display: flex; align-items: center; gap: 5px; padding: 0; border: 0; background: none; color: #7e8c6f; font-size: 11px; }
.history-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
.history-actions > button { display: flex; align-items: center; gap: 5px; padding: 0; border: 0; background: none; color: #7e8c6f; font-size: 11px; }
.annotation-sidebar { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--border); background: #fcfdf8; }
.editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 24px 21px 17px; border-bottom: 1px solid #e5ebdb; }
@@ -252,23 +253,6 @@ button.transcript-time:hover { color: #6c8455; text-decoration: underline; }
.empty-editor p { font-size: 12px; line-height: 1.8; }
.empty-marker { display: grid; place-items: center; width: 47px; height: 47px; margin: 0 auto 14px; border: 1px dashed #d9e3c9; border-radius: 12px; background: #f3f8e9; color: #748167; }
.empty-marker svg { width: 21px; height: 21px; }
.annotation-list-section { padding: 17px 21px 18px; border-top: 1px solid #e3eada; }
.list-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.list-heading h3 { margin: 0; color: #738b5f; font-size: 11px; font-weight: 500; }
.list-heading h3 > span { margin-left: 7px; }
.list-heading span { color: #a0af8d; font-family: var(--font-mono); font-size: 11px; }
.annotation-list { display: flex; flex-direction: column; gap: 4px; max-height: 211px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4dfc6 transparent; }
.annotation-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: #748167; text-align: left; font-size: 11px; }
.annotation-item:hover { background: #f2f7e9; }
.annotation-item.selected { border-color: #e2e9d7; background: #eff5e5; }
.annotation-item > span:first-child { font-family: var(--font-mono); font-size: 11px; color: #748167; }
.annotation-item strong { font-family: var(--font-mono); font-weight: 500; font-size: 11px; color: #7e9469; }
.annotation-item > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.annotation-item > div > small { display: block; overflow: hidden; max-width: 150px; color: #829274; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
.annotation-item .label-pill { margin-left: auto; }
.label-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 5px; border: 1px solid #dce6cc; border-radius: 4px; background: #f0f6e5; color: #748167; font-size: 11px; white-space: nowrap; }
.label-pill.negative { border-color: #ecdbc3; background: #fbf2e6; color: #b39a70; }
.label-pill.pending { border-color: #e1e7d6; background: #f5f8ee; color: #748167; }
.keyboard-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 9px; margin-top: auto; padding: 13px 25px; border-top: 1px solid #e5ecdb; background: #fafcf4; color: #748167; font-size: 11px; }
.keyboard-footer > span { display: inline-flex; align-items: center; gap: 4px; }
.keyboard-footer > span i { display: inline-block; width: 3px; height: 3px; margin-inline: 5px; border-radius: 50%; background: #b6c3a6; }
@@ -303,7 +287,7 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
@media (max-width: 1200px) {
.workspace-grid { grid-template-columns: 208px minmax(0, 1fr) 300px; }
.main-workspace { padding-inline: 21px; }
.editor-heading, .editor-body, .annotation-list-section { padding-left: 17px; padding-right: 17px; }
.editor-heading, .editor-body { padding-left: 17px; padding-right: 17px; }
.topbar { gap: 14px; }
.save-status { display: none; }
.player-secondary { gap: 7px; }
@@ -315,12 +299,20 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
.main-workspace { padding: 25px 25px 18px; }
.annotation-sidebar { grid-column: 2; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; border-top: 1px solid var(--border); border-left: 0; }
.editor-heading { grid-column: span 2; }
.annotation-list-section { border-top: 0; border-left: 1px solid #e3eada; margin: 18px 0; padding-top: 0; }
.keyboard-footer { grid-column: span 2; }
.topbar { padding-inline: 19px; }
.workspace-tag { display: none; }
}
@media (min-width: 900px) and (max-width: 1020px) {
.workspace-grid { grid-template-columns: 176px minmax(0, 1fr) 270px; }
.sidebar { grid-row: auto; padding-inline: 12px; }
.main-workspace { padding: 22px 14px 18px; }
.annotation-sidebar { grid-column: auto; display: flex; border-top: 0; border-left: 1px solid var(--border); }
.player { flex-wrap: wrap; gap: 10px; }
.player-secondary { width: 100%; justify-content: space-between; }
}
@media (max-width: 720px) {
.topbar { min-height: 65px; padding: 12px 15px; gap: 9px; flex-wrap: wrap; }
.brand { gap: 7px; font-size: 15px; }
@@ -363,8 +355,6 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
.annotation-sidebar { display: flex; border-top: 1px solid var(--border); }
.editor-heading { padding: 18px 19px 15px; }
.editor-body { padding: 17px 19px 18px; }
.annotation-list-section { margin: 0; padding: 16px 19px 19px; border-left: 0; border-top: 1px solid #e3eada; }
.annotation-list { max-height: 210px; }
.keyboard-footer { padding: 14px 19px; }
.workspace-footer { flex-wrap: wrap; }
.modal-backdrop { padding: 14px; }
@@ -384,3 +374,4 @@ kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
.transcript-editor-actions { display: flex; align-items: center; gap: 8px; }
+131 -63
View File
@@ -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>;
}
@@ -1,21 +0,0 @@
.panel { margin-top: 18px; overflow: hidden; }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 19px 12px; }
.heading h2 { margin: 0 0 4px; color: #52663f; font-size: 14px; }
.heading p { margin: 0; color: #748167; font-size: 11px; }
.heading h2 { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.details > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; padding: 0 19px 15px; color: #597448; font-size: 12px; }
.details > summary::after { content: "+"; font-size: 18px; }
.details[open] > summary::after { content: "−"; }
.details > summary > span { margin-left: auto; color: #748167; font-size: 11px; }
.note { margin: 0 19px 12px; padding: 9px 12px; background: #faf5eb; border: 1px solid #eadbc5; border-radius: 7px; color: #89704e; font-size: 11px; }
.list { max-height: 380px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d3ddc6 transparent; }
.record { padding: 13px 19px 16px; border-top: 1px solid #e8ecdf; }
.recordHeading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: #748167; font-size: 11px; }
.recordHeading strong { font-weight: 600; color: #647a50; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 24px; margin: 0; }
.fields > div { min-width: 0; }
.wide { grid-column: 1 / -1; }
.fields dt { margin-bottom: 4px; color: #748167; font-size: 11px; }
.fields dd { margin: 0; color: #4e603f; font-size: 13px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.source { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 14px; padding: 10px 19px; border-top: 1px solid #e8ecdf; background: #fafbf5; color: #748167; font-size: 11px; overflow-wrap: anywhere; }
@media (max-width: 600px) { .fields { grid-template-columns: minmax(0, 1fr); } }
@@ -1,33 +0,0 @@
"use client";
import type { CallReference } from "@/lib/call-reference";
import styles from "./call-reference.module.css";
export default function CallReferencePanel({ references }: { references: CallReference[] }) {
if (!references.length) return null;
const source = references[0].source;
const sourceName = source.file.split("/").at(-1);
return <section className={`panel ${styles.panel}`} aria-label="原表校对参考">
<div className={styles.heading}>
<div><h2>原表校对参考 <span className="count-badge">{references.length} 个片段</span></h2><p>原表片段没有时间对齐,需回听录音定位后填写 EOT。</p></div>
</div>
<details className={styles.details} key={references[0].call_id}>
<summary>查看这通录音的原表片段 <span>供回听核对</span></summary>
<p className={styles.note}>原表的抢话结论保留作参考。第 5 列没有表头,有值时按原样显示;EOT 标签需针对具体判断时刻重新核对。</p>
<div className={styles.list}>
{references.map((reference, index) => <article key={`${reference.source.sheet}-${reference.source.row}`} className={styles.record}>
<div className={styles.recordHeading}><strong>片段 {String(index + 1).padStart(2, "0")}</strong><span>原表第 {reference.source.row} 行</span></div>
<dl className={styles.fields}>
{reference.fields.map((field, fieldIndex) => <div key={fieldIndex} className={field.label.includes("说话") || field.value.length > 40 ? styles.wide : undefined}>
<dt>{field.label}</dt><dd>{field.value || "未填写"}</dd>
</div>)}
</dl>
</article>)}
</div>
</details>
<div className={styles.source}>
<span>{sourceName}</span><span>{source.sheet} · 第 {references.map(row => row.source.row).join("、")} 行</span>
</div>
</section>;
}
@@ -14,7 +14,7 @@
padding: 18px 22px 15px;
}
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout, .decisionControls, .decisionControls > label {
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout {
display: flex;
align-items: center;
}
@@ -33,68 +33,67 @@
.zoom button:not(:disabled):hover { background: #edf0e6; }
.zoom > span { width: 30px; text-align: center; font-size: 11px; color: #646d5c; }
.trackLayout { display: grid; grid-template-columns: 88px minmax(0, 1fr); border-block: 1px solid #ecede5; }
.trackLayout { display: grid; grid-template-columns: 98px minmax(0, 1fr); border-block: 1px solid #ecede5; }
.trackNames { background: #fbfcf7; border-right: 1px solid #e9ebe2; font-size: 11px; color: #828b7b; user-select: none; }
.trackNames > div { padding-left: 21px; display: flex; align-items: center; gap: 7px; border-bottom: 1px solid #eceee6; }
.rulerName { height: 32px; font-size: 10px; }
.waveName { height: 76px; }
.speechName { height: 48px; }
.speechName { height: 64px; }
.markerName { height: 44px; border-bottom: 0 !important; }
.agentDot, .userDot, .playheadDot, .decisionDot { display: inline-block; flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.agentDot, .userDot, .playheadDot { display: inline-block; flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.agentDot { background: #b2bca8; }
.userDot { background: #d2b48d; }
.playheadDot { background: #566653; }
.decisionDot { background: #d66d40; }
.viewport { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; scrollbar-color: #cbd1c0 #f8faf2; }
.viewport::-webkit-scrollbar { height: 7px; }
.viewport::-webkit-scrollbar-thumb { background: #cbd1c0; border-radius: 4px; }
.viewport::-webkit-scrollbar-track { background: #f8faf2; }
.canvas { width: var(--timeline-width); height: 248px; position: relative; outline: none; touch-action: pan-y; cursor: crosshair; }
.canvas { width: var(--timeline-width); position: relative; outline: none; touch-action: pan-y; cursor: crosshair; }
.canvas:focus-visible { box-shadow: inset 0 0 0 2px #9aa987; }
.ruler { position: relative; height: 32px; background: #fbfcf7; border-bottom: 1px solid #eceee6; overflow: hidden; }
.tick { position: absolute; top: 0; height: 32px; padding-top: 8px; padding-left: 6px; color: #8b9384; font-family: var(--font-geist-mono, monospace); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tick::after { content: ""; position: absolute; left: 0; bottom: 0; width: 1px; height: 5px; background: #cbd1c1; }
.waveTrack { height: 76px; position: relative; background: #fdfef9; border-bottom: 1px solid #eceee6; }
.waveform { display: block; width: 100%; height: 76px; color: #a3ad98; pointer-events: none; }
.waveform { display: block; width: 100%; height: 76px; color: inherit; pointer-events: none; }
.waveEmpty { position: sticky; left: 0; height: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; color: #929b88; font-size: 11px; }
.waveEmpty svg { color: #b2baa9; }
.speechTrack { position: relative; height: 48px; border-bottom: 1px solid #eceee6; background: #fffefb; }
.speechBlock { position: absolute; top: 9px; height: 29px; overflow: hidden; border: 1px solid; border-radius: 5px; transition: filter .12s; }
.speechTrack { position: relative; height: 64px; border-bottom: 1px solid #eceee6; background: #fffefb; }
.speechBlock { position: absolute; top: 10px; height: 44px; border: 1px solid; border-radius: 5px; transition: filter .12s; }
.speechBlock:hover { filter: brightness(.96); }
.speechBlock > span { display: block; overflow: hidden; padding: 6px 9px; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; line-height: 15px; }
.blockBody { width: 100%; height: 100%; padding: 0 5px; border: 0; border-radius: inherit; background: transparent; color: inherit; text-align: left; cursor: pointer; overflow: hidden; }
.blockBody > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11px; line-height: 15px; }
.resizeHandle { position: absolute; z-index: 4; top: 0; bottom: 0; width: 9px; padding: 0; border: 0; background: transparent; cursor: ew-resize; touch-action: none; }
.resizeHandle::after { content: ""; position: absolute; width: 2px; top: 8px; bottom: 8px; left: 3px; border-radius: 2px; background: currentColor; opacity: .6; }
.startHandle { left: -4px; }
.endHandle { right: -4px; }
.resizeHandle:hover::after, .resizeHandle:focus-visible::after { opacity: 1; width: 3px; }
.resizeHandle:focus-visible { outline: 2px solid #758962; border-radius: 4px; }
.resizingBlock, .editingBlock { box-shadow: 0 0 0 2px #96a983; z-index: 5; }
.resizeReadout { position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%); padding: 4px 6px; color: #fff; background: #526a42; border-radius: 4px; font-size: 10px; white-space: nowrap; pointer-events: none; }
.agentBlock { background: #eef2e5; border-color: #dfe6d2; color: #697854; }
.userBlock { background: #f8f0e5; border-color: #eadcc6; color: #8d7353; }
.annotationTrack { height: 44px; position: relative; background: #fbfcf7; }
.annotation { z-index: 4; position: absolute; top: 13px; transform: translateX(-50%); display: grid; place-items: center; width: 22px; height: 23px; border-radius: 5px; border: 1px solid; cursor: pointer; font-family: var(--font-geist-mono, monospace); font-size: 11px; font-weight: 650; }
.annotationTrack:focus-visible, .speechBlock:focus-visible { outline: 2px solid #758962; outline-offset: -2px; }
.annotation { z-index: 4; position: absolute; top: 13px; transform: translateX(-50%); display: grid; place-items: center; width: 22px; height: 23px; border-radius: 5px; border: 1px solid; cursor: ew-resize; touch-action: none; font-family: var(--font-geist-mono, monospace); font-size: 11px; font-weight: 650; }
.annotation:hover { filter: brightness(.94); }
.annotationStem { position: absolute; width: 1px; height: 11px; top: -12px; left: 50%; background: currentColor; opacity: .4; }
.positive { background: #e8efdb; color: #62764a; border-color: #d4dfc2; }
.negative { background: #f6e4d9; color: #ab7150; border-color: #edcfbc; }
.pending { background: #f0f1eb; color: #87917c; border-color: #dce0d3; }
.selectedLabel { position: absolute; z-index: 3; top: 13px; transform: translateX(-50%); padding: 4px 9px; border: 1px solid #efc7b4; border-radius: 5px; background: #fcf0e8; color: #b86038; font-size: 10px; white-space: nowrap; }
.playhead { position: absolute; z-index: 5; top: 25px; bottom: 0; width: 1px; background: #6b7a5c; pointer-events: none; }
.playhead > span { position: absolute; top: -5px; left: -3px; width: 7px; height: 7px; border-radius: 50%; background: #6b7a5c; }
.selectedLabel { position: absolute; z-index: 3; top: 13px; transform: translateX(-50%); padding: 4px 9px; border: 1px solid #efc7b4; border-radius: 5px; background: #fcf0e8; color: #b86038; font-size: 10px; white-space: nowrap; cursor: ew-resize; touch-action: none; }
.playhead { position: absolute; z-index: 7; top: 25px; bottom: 0; width: 1px; background: #6b7a5c; pointer-events: none; }
.playheadHandle { position: absolute; top: -6px; left: -10px; width: 21px; height: 18px; padding: 0; border: 0; background: transparent; cursor: ew-resize; pointer-events: auto; touch-action: none; }
.playheadHandle > span { display: block; margin: auto; width: 10px; height: 9px; background: #526a42; clip-path: polygon(0 0, 100% 0, 50% 100%); }
.playheadHandle:focus-visible { outline: 2px solid #526a42; border-radius: 3px; }
.snapGuide { position: absolute; z-index: 5; top: 32px; bottom: 0; width: 2px; background: #78aa88; box-shadow: 0 0 0 2px #78aa8820; pointer-events: none; }
.decisionMarker { position: absolute; z-index: 6; top: 0; bottom: 0; width: 0; pointer-events: none; }
.decisionLine { position: absolute; top: 23px; bottom: 0; left: -1px; border-left: 1.5px dashed #d66d40; }
.decisionHandle { position: absolute; left: -14px; top: 1px; display: grid; place-items: center; width: 28px; height: 30px; padding: 0; border: 0; background: transparent; cursor: ew-resize; pointer-events: auto; touch-action: none; }
.decisionHandle > span { width: 14px; height: 19px; background: #d66d40; clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%); border-radius: 2px; transition: transform .15s; }
.decisionHandle:hover > span { transform: scale(1.13); }
.decisionHandle:focus-visible { outline: 2px solid #b95b32; outline-offset: 1px; border-radius: 4px; background: #faeadf; }
.annotation:focus-visible, .tools button:focus-visible { outline: 2px solid #758962; outline-offset: 3px; }
.annotation:focus-visible, .selectedLabel:focus-visible, .tools button:focus-visible { outline: 2px solid #758962; outline-offset: 3px; }
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 7px; }
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 14px; }
.timeReadout { gap: 6px; color: #8b9382; font-size: 10px; }
.timeReadout strong { margin-left: 3px; font-family: var(--font-geist-mono, monospace); color: #66735b; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
.help { font-size: 10px; color: #9aa18f; }
.decisionControls { gap: 12px; padding: 5px 22px 15px; }
.decisionControls > label:first-child { gap: 6px; color: #947158; font-size: 10px; white-space: nowrap; }
.decisionControls > input { flex: 1; min-width: 40px; height: 16px; accent-color: #d66d40; cursor: pointer; }
.decisionControls > input:disabled { opacity: .35; cursor: default; }
.numberInput { width: 99px; gap: 4px; padding: 4px 7px; border: 1px solid #e7e8df; border-radius: 6px; background: #fcfcf7; }
.numberInput:focus-within { border-color: #d6a58b; box-shadow: 0 0 0 2px #f9eadf; }
.numberInput input { width: 100%; min-width: 0; border: 0; background: transparent; outline: none; color: #8c6952; text-align: right; font-family: var(--font-geist-mono, monospace); font-size: 11px; -moz-appearance: textfield; }
.numberInput input::-webkit-inner-spin-button, .numberInput input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.numberInput span { color: #a6ad9d; font-size: 10px; }
@media (max-width: 640px) {
.toolbar { gap: 7px; padding: 14px 12px 12px; }
@@ -103,14 +102,18 @@
.duration { font-size: 10px; }
.tools { gap: 6px; }
.locate { padding: 0; font-size: 10px !important; }
.trackLayout { grid-template-columns: 65px minmax(0, 1fr); }
.trackLayout { grid-template-columns: 82px minmax(0, 1fr); }
.trackNames > div { padding-left: 10px; gap: 5px; font-size: 10px; }
.bottomBar { padding-inline: 12px; }
.help { max-width: 160px; text-align: right; font-size: 9px; }
.decisionControls { gap: 8px; padding-inline: 12px; }
.numberInput { width: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.speechBlock, .decisionHandle > span { transition: none; }
.speechBlock { transition: none; }
}
.leftChannel { color: #6c917c; background: #f6faf5; }
.rightChannel { color: #658cab; background: #f4f8fb; }
.leftDot, .rightDot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.leftDot { background: #6c917c; }
.rightDot { background: #658cab; }
+208 -118
View File
@@ -11,8 +11,10 @@ import {
type PointerEvent,
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
export interface TimelineTranscript {
index: number;
role: "agent" | "user";
start_ms: number;
end_ms: number;
@@ -33,11 +35,15 @@ export interface TimelineProps {
transcripts: TimelineTranscript[];
annotations: TimelineAnnotation[];
selectedId: string | null;
waveform: number[] | null;
editingTranscriptIndex: number | null;
waveform: number[][] | null;
readOnly?: boolean;
onSeek: (ms: number) => void;
onDecisionChange: (ms: number) => void;
onSelectAnnotation: (id: string) => void;
onMoveAnnotation: (id: string, ms: number) => void;
onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
onSelectTranscript: (index: number) => void;
onResizeTranscript: (index: number, startMs: number, endMs: number) => void;
onAddAnnotation: (ms: number) => void;
}
const ZOOM_LEVELS = [1, 2, 4, 8];
@@ -66,20 +72,32 @@ export default function Timeline({
transcripts,
annotations,
selectedId,
editingTranscriptIndex,
waveform,
readOnly = false,
onSeek,
onDecisionChange,
onMoveAnnotation,
onSelectAnnotation,
onSelectTranscript,
onResizeTranscript,
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const rangeId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null);
const draggingRef = useRef<
| { kind: "seek"; pointerId: number }
| { kind: "decision"; pointerId: number; id: string; start: number; next: number }
| { kind: "resize"; pointerId: number; index: number; edge: "start" | "end"; start: number; end: number; nextStart: number; nextEnd: number }
| null
>(null);
const zoomAnchorRef = useRef<number | null>(null);
const suppressAnnotationClickRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(800);
const [zoom, setZoom] = useState(1);
const [resizePreview, setResizePreview] = useState<{ index: number; start_ms: number; end_ms: number } | null>(null);
const [snappedPoint, setSnappedPoint] = useState<SnapPoint | null>(null);
const [decisionPreview, setDecisionPreview] = useState<{ id: string; ms: number } | null>(null);
const duration = Math.max(
1_000,
@@ -91,9 +109,17 @@ export default function Timeline({
const canvasWidth = Math.max(360, viewportWidth) * zoom;
const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value))));
const current = clamp(currentMs);
const decision = decisionMs === null ? null : clamp(decisionMs);
const decision = decisionPreview?.id === selectedId ? decisionPreview.ms : decisionMs === null ? null : clamp(decisionMs);
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
const activeSnap = snappedPoint && (resizePreview || decisionPreview || current === snappedPoint.ms || decision === snappedPoint.ms) ? snappedPoint : null;
const displayedTranscripts = transcripts.map(item => resizePreview?.index === item.index ? { ...item, ...resizePreview } : item);
const snapPoints = useMemo(() => [
...annotations.map(item => ({ ms: item.decision_ms, label: "EOT 判断点", annotationId: item.id })),
...transcripts.flatMap(item => [
{ ms: item.start_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段起点`, index: item.index },
{ ms: item.end_ms, label: `${item.role === "agent" ? "机器人" : "用户"}片段终点`, index: item.index },
]),
], [annotations, transcripts]);
useEffect(() => {
const viewport = viewportRef.current;
@@ -119,23 +145,23 @@ export default function Timeline({
return result;
}, [duration, canvasWidth]);
const waveformPath = useMemo(() => {
if (!waveform?.length) return "";
const waveformPaths = useMemo(() => (waveform || []).map(channel => {
if (!channel.length) return "";
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = waveform.length / barCount;
const barCount = Math.min(channel.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = channel.length / barCount;
let path = "";
for (let bar = 0; bar < barCount; bar++) {
const start = Math.floor(bar * bucketSize);
const end = Math.min(waveform.length, Math.ceil((bar + 1) * bucketSize));
const end = Math.min(channel.length, Math.ceil((bar + 1) * bucketSize));
let peak = 0;
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[index]))));
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(channel[index]))));
const x = ((bar + 0.5) / barCount) * canvasWidth;
const halfHeight = Math.max(0.75, peak * 28);
path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`;
}
return path;
}, [waveform, canvasWidth]);
}), [waveform, canvasWidth]);
function changeZoom(nextZoom: number) {
const viewport = viewportRef.current;
@@ -148,29 +174,69 @@ export default function Timeline({
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
}
function pointerTime(event: PointerEvent<HTMLElement>) {
function pointerTime(event: { clientX: number }) {
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return current;
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
}
function beginDrag(event: PointerEvent<HTMLElement>, kind: "seek" | "decision") {
if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return;
function magneticTime(event: { clientX: number; altKey: boolean }, excludeIndex?: number, excludeAnnotationId?: string) {
const points: SnapPoint[] = snapPoints.filter(point =>
!(excludeIndex !== undefined && "index" in point && point.index === excludeIndex) &&
!(excludeAnnotationId !== undefined && "annotationId" in point && point.annotationId === excludeAnnotationId));
if (excludeIndex !== undefined || excludeAnnotationId !== undefined) points.push({ ms: current, label: "播放位置" });
const result = snapTimelineTime(pointerTime(event), points, duration, canvasWidth, event.altKey);
setSnappedPoint(result.point);
return result.ms;
}
function beginDrag(event: PointerEvent<HTMLElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind, pointerId: event.pointerId };
if (kind === "seek") onSeek(pointerTime(event));
draggingRef.current = { kind: "seek", pointerId: event.pointerId };
onSeek(magneticTime(event));
}
function beginDecisionDrag(event: PointerEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || event.button !== 0) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
suppressAnnotationClickRef.current = null;
draggingRef.current = { kind: "decision", pointerId: event.pointerId, id: item.id, start: item.decision_ms, next: item.decision_ms };
setDecisionPreview({ id: item.id, ms: item.decision_ms });
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
}
function beginResize(event: PointerEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
if (readOnly || event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind: "resize", pointerId: event.pointerId, index: item.index, edge, start: item.start_ms, end: item.end_ms, nextStart: item.start_ms, nextEnd: item.end_ms };
setResizePreview({ index: item.index, start_ms: item.start_ms, end_ms: item.end_ms });
}
function moveDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
const ms = pointerTime(event);
if (drag.kind === "seek") onSeek(ms);
else onDecisionChange(ms);
if (drag.kind === "resize") {
const ms = magneticTime(event, drag.index);
const range = resizeTranscriptRange(drag.start, drag.end, drag.edge, ms, duration);
drag.nextStart = range.start_ms; drag.nextEnd = range.end_ms;
setResizePreview({ index: drag.index, ...range });
if ((drag.edge === "start" ? range.start_ms : range.end_ms) !== ms) setSnappedPoint(null);
} else if (drag.kind === "seek") onSeek(magneticTime(event));
else {
drag.next = magneticTime(event, undefined, drag.id);
setDecisionPreview({ id: drag.id, ms: drag.next });
}
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
const viewport = viewportRef.current;
if (viewport) {
@@ -181,15 +247,45 @@ export default function Timeline({
}
function endDrag(event: PointerEvent<HTMLElement>) {
if (draggingRef.current?.pointerId !== event.pointerId) return;
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
draggingRef.current = null;
if (drag.kind === "resize" && (drag.start !== drag.nextStart || drag.end !== drag.nextEnd)) onResizeTranscript(drag.index, drag.nextStart, drag.nextEnd);
if (drag.kind === "decision" && drag.start !== drag.next) {
suppressAnnotationClickRef.current = drag.id;
onMoveAnnotation(drag.id, drag.next);
}
setResizePreview(null);
setDecisionPreview(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>, kind: "seek" | "decision") {
if (kind === "decision" && readOnly) return;
const base = kind === "seek" ? current : decision;
if (base === null) return;
function cancelDrag() { if (!draggingRef.current) return; draggingRef.current = null; setResizePreview(null); setDecisionPreview(null); setSnappedPoint(null); }
function keyboardAnnotation(event: KeyboardEvent<HTMLElement>, item: TimelineAnnotation) {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const ms = event.key === "Home" ? 0 : event.key === "End" ? duration : item.decision_ms + (["ArrowLeft", "ArrowDown"].includes(event.key) ? -step : step);
setSnappedPoint(null);
onSelectAnnotation(item.id, false);
onMoveAnnotation(item.id, clamp(ms));
}
function keyboardResize(event: KeyboardEvent<HTMLElement>, item: TimelineTranscript, edge: "start" | "end") {
event.stopPropagation();
if (readOnly || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const step = event.shiftKey ? 1000 : event.altKey ? 10 : 100;
const base = edge === "start" ? item.start_ms : item.end_ms;
const value = event.key === "Home" ? 0 : event.key === "End" ? duration : base + (event.key === "ArrowLeft" ? -step : step);
const range = resizeTranscriptRange(item.start_ms, item.end_ms, edge, value, duration);
if (range.start_ms !== item.start_ms || range.end_ms !== item.end_ms) onResizeTranscript(item.index, range.start_ms, range.end_ms);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>) {
const base = current;
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
let next: number | null = null;
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
@@ -199,18 +295,19 @@ export default function Timeline({
if (next === null) return;
event.preventDefault();
event.stopPropagation();
if (kind === "seek") onSeek(clamp(next));
else onDecisionChange(clamp(next));
onSeek(clamp(next));
}
const trackStyle = { "--timeline-width": `${canvasWidth}px` } as CSSProperties;
const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 1}`;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<span className={styles.title}>对话时间轴</span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长</span></span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长{waveform && ` · ${channelCount === 2 ? "双声道" : `${channelCount} 声道`}`}</span></span>
</div>
<div className={styles.tools}>
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
@@ -227,7 +324,7 @@ export default function Timeline({
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
<div className={styles.waveName}>录音</div>
{Array.from({ length: channelCount }, (_, index) => <div className={styles.waveName} key={index}><span className={index % 2 ? styles.rightDot : styles.leftDot} />{waveform ? channelLabel(index) : "录音"}</div>)}
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
@@ -240,83 +337,104 @@ export default function Timeline({
tabIndex={0}
role="group"
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
onPointerDown={(event) => beginDrag(event, "seek")}
onPointerDown={(event) => beginDrag(event)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={() => { draggingRef.current = null; }}
onKeyDown={(event) => keyboardMove(event, "seek")}
onPointerCancel={cancelDrag}
onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}
>
<div className={styles.ruler} aria-hidden="true">
{ticks.map((tick) => <span key={tick.time} className={styles.tick} style={{ left: position(tick.time) }}>{tick.label}</span>)}
</div>
<div className={styles.waveTrack}>
{waveformPath ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label="录音真实振幅波形">
<path d={waveformPath} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4m4-7v10m4-13v16m4-13v10m4-7v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
<span>载入录音后显示波形</span>
</div>
)}
</div>
{Array.from({ length: channelCount }, (_, index) => (
<div key={index} className={`${styles.waveTrack} ${index % 2 ? styles.rightChannel : styles.leftChannel}`}>
{waveformPaths[index] ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label={`${channelLabel(index)}真实振幅波形`}>
<line x1="0" y1="38" x2={canvasWidth} y2="38" stroke="currentColor" opacity=".15" />
<path d={waveformPaths[index]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}><span>载入录音后显示各声道波形</span></div>
)}
</div>
))}
{(["agent", "user"] as const).map((role) => (
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}>
{transcripts.filter((item) => item.role === role).map((item, index) => {
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}
role="group"
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (event.target === event.currentTarget) onSeek(magneticTime(event)); }}
title="点击片段在右侧编辑;拖动片段左右边界调整时间">
{displayedTranscripts.filter((item) => item.role === role).map((item) => {
const start = position(item.start_ms);
const end = position(item.end_ms);
return <span
key={`${role}-${item.start_ms}-${index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock}`}
return <div
key={`${role}-${item.index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock} ${resizePreview?.index === item.index ? styles.resizingBlock : ""} ${editingTranscriptIndex === item.index ? styles.editingBlock : ""}`}
style={{ left: start, width: Math.max(3, end - start) }}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
><span>{item.transcript}</span></span>;
>
<button type="button" className={styles.blockBody}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
aria-label={`${readOnly ? "查看" : "编辑"}${role === "agent" ? "机器人" : "用户"}片段 ${timeLabel(item.start_ms, true)} ${item.transcript}`}
onPointerDown={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); onSelectTranscript(item.index); }}
><span>{item.transcript}</span></button>
{!readOnly && (["start", "end"] as const).map(edge => <button type="button" key={edge}
className={`${styles.resizeHandle} ${edge === "start" ? styles.startHandle : styles.endHandle}`}
aria-label={`调整${role === "agent" ? "机器人" : "用户"}片段${edge === "start" ? "起点" : "终点"} ${timeLabel(item.start_ms, true)}`}
title="拖动调整边界;方向键微调,按住 Alt 暂停吸附"
onPointerDown={event => beginResize(event, item, edge)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={event => keyboardResize(event, item, edge)} onClick={event => event.stopPropagation()}
/>)}
{resizePreview?.index === item.index && <span className={styles.resizeReadout}>{timeLabel(item.start_ms, true)} — {timeLabel(item.end_ms, true)}</span>}
</div>;
})}
</div>
))}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注">
{annotations.filter((item) => item.id !== selectedId).map((item, index) => (
<button
type="button"
key={item.id}
className={`${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(item.decision_ms) }}
title={`标注 ${index + 1} · ${timeLabel(item.decision_ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}`}
aria-label={`选择 ${timeLabel(item.decision_ms, true)} 的标注,${annotationLabel(item.can_reply_now, item.reason)}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onSelectAnnotation(item.id); }}
onKeyDown={(event) => event.stopPropagation()}
><span className={styles.annotationStem} aria-hidden="true" /><span>{item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>
))}
{decision !== null && <span className={styles.selectedLabel} style={{ left: position(decision) }}>
{selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"}
</span>}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注"
role="group" tabIndex={readOnly ? -1 : 0}
title={readOnly ? "点击判断点查看" : "点击空白处新增判断点;Enter 在播放位置新增"}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); if (!readOnly) onAddAnnotation(magneticTime(event)); }}
onKeyDown={event => { event.stopPropagation(); if (!readOnly && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onAddAnnotation(current); } }}>
{annotations.map((item, index) => {
const ms = decisionPreview?.id === item.id ? decisionPreview.ms : item.decision_ms;
const isSelected = item.id === selectedId;
return <button type="button" key={item.id}
className={isSelected ? styles.selectedLabel : `${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(ms), cursor: readOnly ? "pointer" : undefined }}
title={`标注 ${index + 1} · ${timeLabel(ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : " · 拖动调整时间,Alt 暂停吸附"}`}
aria-label={`${isSelected ? "当前" : "选择"}标注 ${timeLabel(ms, true)},${annotationLabel(item.can_reply_now, item.reason)}${readOnly ? "" : ",可拖动"}`}
aria-describedby={helpId}
onPointerDown={event => beginDecisionDrag(event, item)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onClick={event => {
event.stopPropagation();
if (suppressAnnotationClickRef.current === item.id) { suppressAnnotationClickRef.current = null; return; }
onSelectAnnotation(item.id);
}}
onKeyDown={event => keyboardAnnotation(event, item)}
><span className={styles.annotationStem} aria-hidden="true" /><span>{isSelected ? annotationLabel(item.can_reply_now, item.reason) : item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>;
})}
</div>
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></div>
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
title="拖动播放头,自动吸附片段边界和判断点;Alt 暂停吸附"
onPointerDown={event => beginDrag(event)} onPointerMove={moveDrag} onPointerUp={endDrag}
onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag}
onKeyDown={keyboardMove}><span /></button>
</div>
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
<div className={styles.decisionLine} aria-hidden="true" />
<button
type="button"
className={styles.decisionHandle}
title={readOnly ? "当前判断时间(查看模式)" : "拖动调整判断时间;方向键微调 100 ms,Shift 微调 1 秒"}
aria-label={`当前判断点 ${timeLabel(decision, true)}${readOnly ? ",查看模式" : ",拖动或使用方向键调整"}`}
aria-describedby={helpId}
aria-disabled={readOnly}
style={readOnly ? { cursor: "default" } : undefined}
onPointerDown={(event) => { event.stopPropagation(); if (!readOnly) beginDrag(event, "decision"); }}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={(event) => { event.stopPropagation(); draggingRef.current = null; }}
onKeyDown={(event) => { event.stopPropagation(); keyboardMove(event, "decision"); }}
><span aria-hidden="true" /></button>
</div>}
</div>
</div>
@@ -324,35 +442,7 @@ export default function Timeline({
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}</span>
</div>
<div className={styles.decisionControls}>
<label htmlFor={rangeId}><span className={styles.decisionDot} />判断时间</label>
<input
id={rangeId}
type="range"
min={0}
max={duration}
step={1}
value={decision ?? 0}
disabled={decision === null || readOnly}
aria-valuetext={decision === null ? "未选择判断点" : timeLabel(decision, true)}
onChange={(event) => onDecisionChange(clamp(Number(event.currentTarget.value)))}
/>
<label className={styles.numberInput}>
<input
type="number"
aria-label="判断时间,单位毫秒"
min={0}
max={duration}
step={1}
value={decision ?? ""}
placeholder="—"
disabled={decision === null || readOnly}
onChange={(event) => { if (event.currentTarget.value !== "") onDecisionChange(clamp(Number(event.currentTarget.value))); }}
/>
<span>ms</span>
</label>
<span id={helpId} className={styles.help}>{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}</span>
</div>
</section>
);
@@ -0,0 +1,26 @@
"use client";
import type { Transcript } from "@/lib/annotations";
import { formatTime } from "@/lib/annotations";
export default function TranscriptEditor({ draft, readOnly, onChange, onSave, onClose, onDelete }: {
draft: { index: number; row: Transcript };
readOnly: boolean;
onChange: (row: Transcript) => void;
onSave: () => void;
onClose: () => void;
onDelete: () => void;
}) {
const row = draft.row;
return <aside className="annotation-sidebar" aria-label="对话转写编辑栏">
<div className="editor-heading"><div><span className="eyebrow">{draft.index === -1 ? "新增对话转写" : readOnly ? "转写详情" : "编辑对话转写"}</span><h2>{row.role === "agent" ? "机器人" : "用户"}发言</h2></div><button className="icon-button" aria-label="关闭转写编辑" onClick={onClose}>×</button></div>
<div className="editor-body">
<div className="decision-card"><span>片段时间</span><strong>{formatTime(row.start_ms)} — {formatTime(row.end_ms)}</strong><small>也可拖动时间轴上片段的左右边界。</small></div>
<label className="form-field"><span>说话人</span><select aria-label="转写说话人" disabled={readOnly} value={row.role} onChange={event => onChange({ ...row, role: event.target.value as Transcript["role"] })}><option value="agent">机器人</option><option value="user">用户</option></select></label>
<div className="form-two"><label className="form-field"><span>开始时间(毫秒)</span><input aria-label="转写开始时间" type="number" min="0" readOnly={readOnly} value={row.start_ms} onChange={event => onChange({ ...row, start_ms: Number(event.target.value) })} /></label><label className="form-field"><span>结束时间(毫秒)</span><input aria-label="转写结束时间" type="number" min="1" readOnly={readOnly} value={row.end_ms} onChange={event => onChange({ ...row, end_ms: Number(event.target.value) })} /></label></div>
<label className="form-field"><span>转写文本</span><textarea aria-label="转写文本" rows={7} readOnly={readOnly} placeholder="填写这段发言的内容" value={row.transcript} onChange={event => onChange({ ...row, transcript: event.target.value })} /></label>
{!readOnly && <div className="transcript-editor-actions"><button className="button primary" onClick={onSave}>{draft.index === -1 ? "添加片段" : "应用修改"}</button><button className="button ghost" onClick={onClose}>关闭</button></div>}
{!readOnly && draft.index >= 0 && <div className="editor-bottom"><span>修改后保存到项目</span><button className="delete-button" onClick={onDelete}>删除片段</button></div>}
</div>
</aside>;
}
@@ -1,904 +0,0 @@
[
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 2
},
"fields": [
{
"label": "用户说话",
"value": "贵州省遵义市仁怀市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 3
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 4
},
"fields": [
{
"label": "用户说话",
"value": "型号是24年380大众帕萨特星空龙腾版。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 5
},
"fields": [
{
"label": "用户说话",
"value": "24年8月份。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "购车时间和行驶里程方便说一下吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 6
},
"fields": [
{
"label": "用户说话",
"value": "行驶3万公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "行驶里程大概是多少呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 7
},
"fields": [
{
"label": "用户说话",
"value": "恒泰轮胎前门两条轮胎开裂,东西比较大。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 8
},
"fields": [
{
"label": "用户说话",
"value": "对。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "2c83253a-7977-4e3c-a387-0aa1ea06fbe1",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 9
},
"fields": [
{
"label": "用户说话",
"value": "尽快,已经影响行车安全了。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 10
},
"fields": [
{
"label": "用户说话",
"value": "宣传。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 11
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 12
},
"fields": [
{
"label": "用户说话",
"value": "爱车型号是大众帕萨特,购车时间2023年10月份,行驶里程24000多公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 13
},
"fields": [
{
"label": "用户说话",
"value": "轮胎裂纹。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "否"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 14
},
"fields": [
{
"label": "用户说话",
"value": "轮胎裂纹同意更换新轮胎。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "方便说话吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 15
},
"fields": [
{
"label": "用户说话",
"value": "但是20。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "40ae686c-8196-4d7b-b7ad-3e12a427ebb6",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 16
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 17
},
"fields": [
{
"label": "用户说话",
"value": "我姓宋。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "刚才没太听懂您的意思,能不能再跟我说?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 18
},
"fields": [
{
"label": "用户说话",
"value": "郑州市,河南郑州市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 19
},
"fields": [
{
"label": "用户说话",
"value": "我的轮胎开裂了。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 20
},
"fields": [
{
"label": "用户说话",
"value": "帕萨特L2025款2025年3月购车流水流程29000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉,您的问题我已经了解了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 21
},
"fields": [
{
"label": "用户说话",
"value": "29000公里。29000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "行驶里程大概是多少呢?另外,辛苦。行驶里程大概是多少呢?另外辛苦您仔细讲。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "872a1e67-873a-4c49-a598-b38a670afcad",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 22
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好的,您反映的问题我全都记下了,我这边尽快安排给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 23
},
"fields": [
{
"label": "用户说话",
"value": "天津市。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 24
},
"fields": [
{
"label": "用户说话",
"value": "投诉。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 25
},
"fields": [
{
"label": "用户说话",
"value": "迈腾2023款330豪华版2.0T。出现的问题是轮购车时间是2024年5月份,里程是6851公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 26
},
"fields": [
{
"label": "用户说话",
"value": "轮胎质量问题规律,排水槽横向竖向规律。轮胎质量有问题。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细想一想想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "a266b9ef-5e47-4434-a04c-ce4eacd52c19",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 27
},
"fields": [
{
"label": "用户说话",
"value": "可以。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排专员给您回电沟通,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 28
},
"fields": [
{
"label": "用户说话",
"value": "辽宁葫芦岛。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 29
},
"fields": [
{
"label": "用户说话",
"value": "投诉建议。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 30
},
"fields": [
{
"label": "用户说话",
"value": "我是2023年、2024年1月份购置的迈腾3302023款轿车,目前已行驶28000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 31
},
"fields": [
{
"label": "用户说话",
"value": "我的轮胎平时28000公里。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细想一想,想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 32
},
"fields": [
{
"label": "用户说话",
"value": "轮胎开裂,行驶28000公里,轮胎开裂、纵纹开裂、横纹开裂。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 33
},
"fields": [
{
"label": "用户说话",
"value": "对的。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 34
},
"fields": [
{
"label": "用户说话",
"value": "请尽快帮我处理这件事。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好,您反映的问题我全都记下了,我这边尽快安排,您看可以吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "cb3238dc-3946-42a5-aad8-9b9a54226363",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 35
},
"fields": [
{
"label": "用户说话",
"value": "好的。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "好的,感谢您的配合,祝您生活愉快,再见。"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 36
},
"fields": [
{
"label": "用户说话",
"value": "苏州昆山。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您现在在哪个城市呢?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 37
},
"fields": [
{
"label": "用户说话",
"value": "投诉。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "请问您是要业务咨询还是投诉建议?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 38
},
"fields": [
{
"label": "用户说话",
"value": "帕萨特24年11月份。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "抱歉给您带来不好的体验了,麻烦告知一下您的爱车型号、购车时间,还有行驶里程。"
},
{
"label": "用户是否被抢话",
"value": "否"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 39
},
"fields": [
{
"label": "用户说话",
"value": "24年11月份购买的帕萨特,15000公里左右。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "购车时间和行驶里程方便说一下吗?"
},
{
"label": "用户是否被抢话",
"value": "否"
}
]
},
{
"call_id": "e304f740-85d4-427e-9fd6-c5474e9cd737",
"source": {
"file": "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx",
"sheet": "测试数据集",
"row": 40
},
"fields": [
{
"label": "用户说话",
"value": "四条轮胎,最近我在。查看的时候发现全部开裂。"
},
{
"label": "用户说话往前一句机器人说话",
"value": "辛苦您仔细讲一讲想要反馈投诉的具体事情哈。"
},
{
"label": "用户是否被抢话",
"value": "是"
},
{
"label": "原表第5列(无表头)",
"value": "是"
}
]
}
]
-13
View File
@@ -1,13 +0,0 @@
/** Read-only source-table evidence, separate from timed transcripts and EOT labels. */
export interface CallReference {
call_id: string;
source: {
file: string;
sheet: string;
row: number;
};
fields: {
label: string;
value: string;
}[];
}
+21
View File
@@ -0,0 +1,21 @@
export interface EditHistory<T> { past: T[]; present: T; future: T[] }
const LIMIT = 40;
export function createEditHistory<T>(present: T): EditHistory<T> {
return { past: [], present, future: [] };
}
export function commitEdit<T>(history: EditHistory<T>, present: T): EditHistory<T> {
if (present === history.present) return history;
return { past: [...history.past, history.present].slice(-LIMIT), present, future: [] };
}
export function undoEdit<T>(history: EditHistory<T>): EditHistory<T> {
if (!history.past.length) return history;
return { past: history.past.slice(0, -1), present: history.past.at(-1)!, future: [history.present, ...history.future] };
}
export function redoEdit<T>(history: EditHistory<T>): EditHistory<T> {
if (!history.future.length) return history;
return { past: [...history.past, history.present].slice(-LIMIT), present: history.future[0], future: history.future.slice(1) };
}
+25
View File
@@ -0,0 +1,25 @@
export interface SnapPoint { ms: number; label: string }
/** Keep magnetic selection consistent with zoom without attracting distant audio. */
export function snapTimelineTime(ms: number, points: SnapPoint[], duration: number, width: number, bypass = false) {
const bounded = Math.round(Math.max(0, Math.min(duration, ms)));
const radius = Math.min(500, duration / Math.max(1, width) * 8);
let point: SnapPoint | null = null;
let distance = radius;
if (!bypass) {
for (const candidate of points) {
if (candidate.ms < 0 || candidate.ms > duration) continue;
const delta = Math.abs(candidate.ms - bounded);
if (delta <= distance && (!point || delta < distance)) { point = candidate; distance = delta; }
}
}
return { ms: point?.ms ?? bounded, point };
}
/** A resized segment must stay in the recording and retain a positive duration. */
export function resizeTranscriptRange(start: number, end: number, edge: "start" | "end", ms: number, duration: number) {
const value = Math.round(ms);
return edge === "start"
? { start_ms: Math.max(0, Math.min(end - 1, value)), end_ms: end }
: { start_ms: start, end_ms: Math.max(start + 1, Math.min(duration, value)) };
}
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
allowedDevOrigins: ["127.0.0.1"],
// 本地标注与录音接口始终读取当前工作文件。
};
+143
View File
@@ -0,0 +1,143 @@
"""Convert Hantai XLSX rows to an editable, approximate CSV draft.
Requires ffmpeg and numpy. Never writes the source workbook or 工作数据.
"""
import csv
import json
import math
import subprocess
import uuid
import zipfile
from collections import defaultdict
from pathlib import Path
from xml.etree import ElementTree as ET
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx"
OUTPUT = ROOT / "labels/EOT标注/韩泰初稿"
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
def source_rows():
with zipfile.ZipFile(SOURCE) as archive:
strings = []
if "xl/sharedStrings.xml" in archive.namelist():
strings = ["".join(item.itertext()) for item in ET.fromstring(archive.read("xl/sharedStrings.xml"))]
rows = []
for row in ET.fromstring(archive.read("xl/worksheets/sheet1.xml")).findall(".//s:row", NS):
values = {}
for cell in row.findall("s:c", NS):
value = cell.find("s:v", NS)
text = value.text if value is not None else ""
if cell.get("t") == "s":
text = strings[int(text)]
elif cell.get("t") == "inlineStr":
text = "".join(cell.find("s:is", NS).itertext())
values[cell.get("r").rstrip("0123456789")] = text or ""
try:
uuid.UUID(values.get("A", ""))
except ValueError:
continue
rows.append({"row": int(row.get("r")), **values})
return rows
def speech_spans(audio):
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", str(audio), "-f", "f32le", "-ac", "2", "-ar", "8000", "pipe:1"])
samples = np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)
frames = len(samples) // 160
rms = np.sqrt((samples[:frames * 160].reshape(frames, 160, 2) ** 2).mean(axis=1))
channels = []
for channel in range(2):
threshold = max(.004, float(np.quantile(rms[:, channel], .95)) * .16)
runs = []
for index in np.flatnonzero(rms[:, channel] > threshold):
if runs and index - runs[-1][1] < 35:
runs[-1][1] = int(index)
else:
runs.append([int(index), int(index)])
channels.append([(a * 20, (b + 1) * 20) for a, b in runs if b - a >= 12])
return channels, round(len(samples) / 8)
def match_prompts(groups, spans):
# Greeting is absent from the source. Match remaining prompts in order,
# allowing extra robot speech (e.g. a retry) to be skipped.
candidates = spans[1:]
cache = {}
def solve(i, j):
if i == len(groups):
return .35 * (len(candidates) - j), []
if len(candidates) - j < len(groups) - i:
return float("inf"), []
if (i, j) in cache:
return cache[i, j]
expected = max(.6, len(groups[i][0]["C"]) / 6.7)
actual = (candidates[j][1] - candidates[j][0]) / 1000
score, rest = solve(i + 1, j + 1)
best = (score + abs(math.log(actual / expected)), [candidates[j]] + rest)
if len(candidates) - j > len(groups) - i:
score, rest = solve(i, j + 1)
if score + .35 < best[0]:
best = score + .35, rest
cache[i, j] = best
return best
matches = solve(0, 0)[1]
if len(matches) != len(groups):
raise ValueError("Not enough robot speech spans; inspect the recording before conversion.")
return matches
def main():
grouped = defaultdict(list)
for row in source_rows():
grouped[row["A"]].append(row)
transcripts, annotations, provenance = [], [], []
for call, rows in grouped.items():
channels, duration = speech_spans(ROOT / f"data/韩泰机器人抢话/音频/{call}.mp3")
groups = []
for row in rows:
if groups and groups[-1][0]["C"] == row["C"]:
groups[-1].append(row)
else:
groups.append([row])
prompts = match_prompts(groups, channels[1])
for index, (group, (start, end)) in enumerate(zip(groups, prompts)):
transcripts.append([call, "agent", start, end, group[0]["C"]])
window_end = prompts[index + 1][0] if index + 1 < len(prompts) else duration
user_spans = [(a, b) for a, b in channels[0] if a >= end - 300 and a < window_end]
user_start = user_spans[0][0] if user_spans else min(end + 300, window_end - 1)
user_end = user_spans[-1][1] if user_spans else window_end
# Multiple answers to the same robot prompt share its response window.
# These boundaries are provisional, not word-level forced alignment.
weights = [max(1, len(row["B"])) for row in group]
cursor = user_start
for offset, row in enumerate(group):
finish = user_end if offset == len(group) - 1 else user_start + round((user_end - user_start) * sum(weights[:offset + 1]) / sum(weights))
finish = max(cursor + 1, min(duration, finish))
transcripts.append([call, "user", cursor, finish, row["B"]])
conflict = row.get("E") in ("是", "否") and row["E"] != row["D"]
label = "" if conflict else "0" if row["D"] == "是" else "1" if row["D"] == "否" else ""
reason = "uncertain" if label == "" else "unfinished_expression" if label == "0" else "completed_answer"
annotations.append([call, finish, row["B"], label, "low", reason, "xlsx_draft"])
provenance.append({"call_id": call, "source_row": row["row"], "decision_ms": finish, "original_interrupted": row["D"], "unheaded_column_e": row.get("E", ""), "conflict": conflict})
cursor = finish
OUTPUT.mkdir(parents=True, exist_ok=True)
for name, header, records in [
("asr_history.csv", ["call_id", "role", "start_ms", "end_ms", "transcript"], sorted(transcripts, key=lambda r: (r[0], r[2], r[1]))),
("eot_labels.csv", ["call_id", "decision_ms", "user_text_at_decision", "can_reply_now", "confidence", "reason", "annotator"], annotations),
]:
with (OUTPUT / name).open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(header)
writer.writerows(records)
(OUTPUT / "source_rows.json").write_text(json.dumps(provenance, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({"calls": len(grouped), "transcripts": len(transcripts), "annotations": len(annotations), "conflicts": sum(row["conflict"] for row in provenance)}))
if __name__ == "__main__":
main()
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's native TypeScript runner requires the explicit extension.
import { createEditHistory, commitEdit, undoEdit, redoEdit } from "../lib/edit-history.ts";
test("ASR and EOT snapshots restore together through multiple undo and redo steps", () => {
const initial = { transcripts: ["原转写"], points: [1000] };
const asr = { ...initial, transcripts: ["修正转写"] };
const eot = { ...asr, points: [1200, 1800] };
let history = commitEdit(commitEdit(createEditHistory(initial), asr), eot);
history = undoEdit(history);
assert.deepEqual(history.present, asr);
history = undoEdit(history);
assert.deepEqual(history.present, initial);
history = redoEdit(history);
assert.deepEqual(history.present, asr);
history = redoEdit(history);
assert.deepEqual(history.present, eot);
assert.equal(history.future.length, 0);
assert.deepEqual(initial, { transcripts: ["原转写"], points: [1000] });
});
test("a new edit after undo abandons the old redo branch", () => {
let history = commitEdit(commitEdit(createEditHistory(0), 1), 2);
history = commitEdit(undoEdit(history), 3);
assert.equal(history.future.length, 0);
assert.equal(redoEdit(history).present, 3);
assert.equal(undoEdit(history).present, 1);
});
test("empty and unchanged operations preserve history, and forty steps can be restored", () => {
const initial = createEditHistory(0);
assert.equal(undoEdit(initial), initial);
assert.equal(redoEdit(initial), initial);
assert.equal(commitEdit(initial, 0), initial);
let history = initial;
for (let i = 1; i <= 60; i++) history = commitEdit(history, i);
assert.equal(history.past.length, 40);
for (let i = 0; i < 40; i++) history = undoEdit(history);
assert.equal(history.present, 20);
assert.equal(undoEdit(history), history);
for (let i = 0; i < 40; i++) history = redoEdit(history);
assert.equal(history.present, 60);
});
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's native TypeScript runner requires the explicit extension.
import { resizeTranscriptRange, snapTimelineTime } from "../lib/timeline-edit.ts";
test("playhead chooses the nearest boundary or EOT and Alt allows free positioning", () => {
const points = [{ ms: 2000, label: "片段结束" }, { ms: 2150, label: "判断点" }];
assert.equal(snapTimelineTime(2110, points, 10000, 400).ms, 2150);
assert.equal(snapTimelineTime(2030, points, 10000, 400).ms, 2000);
assert.equal(snapTimelineTime(2110, points, 10000, 400, true).ms, 2110);
assert.equal(snapTimelineTime(4000, points, 10000, 400).point, null);
});
test("zoom narrows magnetic distance and long calls cannot pull the head by seconds", () => {
const points = [{ ms: 2000, label: "片段" }];
assert.equal(snapTimelineTime(2100, points, 10000, 400).ms, 2000);
assert.equal(snapTimelineTime(2100, points, 10000, 3200).ms, 2100);
assert.equal(snapTimelineTime(2800, points, 600000, 400).ms, 2800);
});
test("resizing never crosses the opposite boundary or the recording ends", () => {
assert.deepEqual(resizeTranscriptRange(2000, 4000, "start", 4500, 10000), { start_ms: 3999, end_ms: 4000 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "end", 1500, 10000), { start_ms: 2000, end_ms: 2001 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "start", -500, 10000), { start_ms: 0, end_ms: 4000 });
assert.deepEqual(resizeTranscriptRange(2000, 4000, "end", 15000, 10000), { start_ms: 2000, end_ms: 10000 });
});