完善韩泰 EOT 标注编辑器并更新标注规范与项目资料
This commit is contained in:
@@ -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` 目录,不能通过 `..` 或符号链接访问目录之外的文件。
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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": "是"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
// 本地标注与录音接口始终读取当前工作文件。
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user