新增 labels 下的韩泰 EOT 标注工作台
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,54 @@
|
||||
# EOT 标注工作台
|
||||
|
||||
基于 Next.js Minimal 模板的本地网页工具,独立放在 `labels/eot-studio`,按项目的 [EOT 标注格式](../EOT标注/EOT逐话轮标注格式.md)查看对话、回放音频并编辑判断位置和标签。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd labels/eot-studio
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开终端显示的本地地址,通常为 <http://localhost:3000>。生产构建使用 `npm run build`,随后 `npm start`。请从 `labels/eot-studio` 目录运行,API 以其上两级作为项目根目录。
|
||||
|
||||
## 使用
|
||||
|
||||
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 会下载当前数据,可交给其他标注员或用于训练和评测。
|
||||
|
||||
草稿在当前浏览器的本地存储中保存,刷新后可恢复。录音来自项目目录或本次选择的本地文件;本地文件的播放地址不会存入草稿。浏览器草稿与项目 CSV 分开,只有明确点击“保存到项目”才会改写项目中的工作数据。原始示例和 `data` 下的录音不会被修改。
|
||||
|
||||
切换数据集只改变浏览范围;导出菜单可选择“当前数据集”或“全部”,项目保存会保留完整数据。韩泰录音旁可展开“原表校对参考”,查看已校对表提取的 39 条原始片段。它们没有起止时间或 EOT 判断时间,因此需回听定位后填写标准转写和标签。原表第 5 列没有表头,提取结果按原值展示;原表结论不会自动转为 EOT 标签。参考数据在 `labels/eot-studio/data/hantai-reference.json` 中,保留源文件、工作表和行号,不会随 CSV 保存被改写。当前工具没有工作 CSV 时,仍载入标准示例供切换查看。
|
||||
|
||||
## CSV 格式
|
||||
|
||||
对话转写 `asr_history.csv`:
|
||||
|
||||
```csv
|
||||
call_id,role,start_ms,end_ms,transcript
|
||||
demo,agent,1000,2400,您什么时候方便接电话?
|
||||
demo,user,3000,4400,我明天下午
|
||||
```
|
||||
|
||||
EOT 标注 `eot_labels.csv`:
|
||||
|
||||
```csv
|
||||
call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator
|
||||
demo,5200,我明天下午,0,high,mid_turn_pause,A
|
||||
```
|
||||
|
||||
`role` 为 `agent` 或 `user`;`can_reply_now` 为 `1`、`0` 或空;`confidence` 为 `high`、`medium`、`low` 或空。时间为非负整数毫秒,转写满足 `start_ms < end_ms`。同一 `call_id + decision_ms` 不能重复。CSV 使用 UTF-8,支持标准引号、逗号和多行文本。
|
||||
|
||||
全部对话保存在转写表,当前用户表达保存在 EOT 表。查看或预测时以通话 ID 和判断时间关联这两份文件;空标签保留为待判断样本。
|
||||
|
||||
## 本地接口
|
||||
|
||||
- `GET /api/workspace`:读取工作数据、项目录音列表和只读 `callReferences` 原表参考。工作数据的两份 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` 目录,不能通过 `..` 或符号链接访问目录之外的文件。
|
||||
|
||||
录音 `call_id` 默认取文件名去除扩展名后的部分;同一通话有多个项目录音时,默认使用发现的第一条,可点击“更换录音”重新关联本地文件。
|
||||
@@ -0,0 +1,109 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const dataRoot = path.resolve(process.cwd(), "..", "..", "data");
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".m4a": "audio/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".webm": "audio/webm",
|
||||
};
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function within(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function failure(message: string, status: number, head: boolean): Response {
|
||||
return new Response(head ? null : message, {
|
||||
status,
|
||||
headers: { "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
function parseRange(value: string, size: number): { start: number; end: number } | null {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
|
||||
if (!match || (!match[1] && !match[2]) || size === 0) return null;
|
||||
if (!match[1]) {
|
||||
const length = Number(match[2]);
|
||||
if (!Number.isSafeInteger(length) || length <= 0) return null;
|
||||
return { start: Math.max(0, size - length), end: size - 1 };
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const requestedEnd = match[2] ? Number(match[2]) : size - 1;
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start >= size || requestedEnd < start) return null;
|
||||
return { start, end: Math.min(requestedEnd, size - 1) };
|
||||
}
|
||||
|
||||
async function serve(request: Request, head: boolean): Promise<Response> {
|
||||
const relativePath = new URL(request.url).searchParams.get("path");
|
||||
if (!relativePath || relativePath.includes("\0") || path.isAbsolute(relativePath)) {
|
||||
return failure("录音路径无效。", 400, head);
|
||||
}
|
||||
const candidate = path.resolve(dataRoot, relativePath);
|
||||
if (!within(dataRoot, candidate)) return failure("不能访问 data 目录之外的文件。", 403, head);
|
||||
const extension = path.extname(candidate).toLowerCase();
|
||||
if (!mimeTypes[extension]) return failure("不支持该录音格式。", 415, head);
|
||||
|
||||
let handle;
|
||||
try {
|
||||
const [realRoot, realCandidate] = await Promise.all([fs.realpath(dataRoot), fs.realpath(candidate)]);
|
||||
if (!within(realRoot, realCandidate) || !mimeTypes[path.extname(realCandidate).toLowerCase()]) {
|
||||
return failure("不能访问 data 目录之外的文件。", 403, head);
|
||||
}
|
||||
handle = await fs.open(realCandidate, "r");
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
await handle.close();
|
||||
return failure("录音不存在。", 404, head);
|
||||
}
|
||||
const headers = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": mimeTypes[path.extname(realCandidate).toLowerCase()],
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
const rangeHeader = request.headers.get("range");
|
||||
const range = rangeHeader ? parseRange(rangeHeader, stat.size) : null;
|
||||
if (rangeHeader && !range) {
|
||||
await handle.close();
|
||||
headers.set("Content-Range", `bytes */${stat.size}`);
|
||||
headers.set("Content-Length", "0");
|
||||
return new Response(null, { status: 416, headers });
|
||||
}
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? stat.size - 1;
|
||||
const status = range ? 206 : 200;
|
||||
headers.set("Content-Length", String(Math.max(0, end - start + 1)));
|
||||
if (range) headers.set("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
||||
if (head || stat.size === 0) {
|
||||
await handle.close();
|
||||
return new Response(null, { status, headers });
|
||||
}
|
||||
const stream = handle.createReadStream({ start, end, autoClose: true });
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, { status, headers });
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR")) return failure("录音不存在。", 404, head);
|
||||
console.error("EOT audio request failed", error);
|
||||
return failure("无法读取录音。", 500, head);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
return serve(request, false);
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request): Promise<Response> {
|
||||
return serve(request, true);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
exportAnnotations,
|
||||
exportTranscripts,
|
||||
parseAnnotations,
|
||||
parseTranscripts,
|
||||
type Annotation,
|
||||
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";
|
||||
|
||||
const projectRoot = path.resolve(process.cwd(), "..", "..");
|
||||
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 lockPath = path.join(savedRoot, ".workspace.lock");
|
||||
const extensions = new Set([".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"]);
|
||||
const maxPayloadBytes = 20 * 1024 * 1024;
|
||||
|
||||
type Recording = { call_id: string; name: string; path: string; url: string };
|
||||
type WorkspaceRuntime = typeof globalThis & { __eotWorkspaceQueue?: Promise<void> };
|
||||
|
||||
class WorkspaceError extends Error {
|
||||
constructor(message: string, readonly status = 422) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown): Response {
|
||||
if (error instanceof WorkspaceError) return json({ error: error.message }, error.status);
|
||||
console.error("EOT workspace request failed", error);
|
||||
return json({ error: "无法读取或保存项目文件,请检查目录和文件权限。" }, 500);
|
||||
}
|
||||
|
||||
// Share the queue across development hot reloads. The disk lock also prevents
|
||||
// two server processes from reading or replacing different halves of a save.
|
||||
function serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const state = globalThis as WorkspaceRuntime;
|
||||
const pending = (state.__eotWorkspaceQueue ?? Promise.resolve()).then(operation);
|
||||
state.__eotWorkspaceQueue = pending.then(() => undefined, () => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function withDiskLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(lockPath, "wx", 0o600);
|
||||
} catch (error) {
|
||||
if (hasCode(error, "EEXIST")) {
|
||||
throw new WorkspaceError("另一个操作正在读取或保存工作数据,请稍后重试。", 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
||||
return await operation();
|
||||
} finally {
|
||||
try {
|
||||
await handle.close();
|
||||
} finally {
|
||||
await fs.unlink(lockPath).catch((error: unknown) => {
|
||||
if (!hasCode(error, "ENOENT")) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRecordings(): Promise<Recording[]> {
|
||||
const result: Recording[] = [];
|
||||
async function visit(directory: string): Promise<void> {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(absolute);
|
||||
} else if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) {
|
||||
const relative = path.relative(dataRoot, absolute).split(path.sep).join("/");
|
||||
result.push({
|
||||
call_id: path.basename(entry.name, path.extname(entry.name)),
|
||||
name: entry.name,
|
||||
path: relative,
|
||||
url: `/api/audio?path=${encodeURIComponent(relative)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await visit(dataRoot);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
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"),
|
||||
fs.readFile(path.join(directory, "eot_labels.csv"), "utf8"),
|
||||
]);
|
||||
try {
|
||||
return { transcripts: parseTranscripts(transcriptsCsv), annotations: parseAnnotations(annotationsCsv) };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "CSV 格式错误";
|
||||
throw new WorkspaceError(`工作数据格式有误:${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function savedAt(): Promise<string> {
|
||||
const stats = await Promise.all([
|
||||
fs.stat(path.join(savedRoot, "asr_history.csv")),
|
||||
fs.stat(path.join(savedRoot, "eot_labels.csv")),
|
||||
]);
|
||||
return new Date(Math.max(...stats.map((stat) => stat.mtimeMs))).toISOString();
|
||||
}
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
return await serialize(async () => {
|
||||
if (await exists(lockPath)) {
|
||||
throw new WorkspaceError("工作数据正在保存,请稍后重试。", 409);
|
||||
}
|
||||
const [hasTranscripts, hasAnnotations, recordings, callReferences] = 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" });
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(row: Record<string, unknown>, name: string, label: string): void {
|
||||
if (typeof row[name] !== "string") throw new WorkspaceError(`${label} 的 ${name} 必须是文本。`);
|
||||
}
|
||||
|
||||
function requireMillis(row: Record<string, unknown>, name: string, label: string): void {
|
||||
if (!Number.isSafeInteger(row[name]) || Number(row[name]) < 0) {
|
||||
throw new WorkspaceError(`${label} 的 ${name} 必须是非负整数毫秒。`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRow(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new WorkspaceError(`${label} 必须是一个记录对象。`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validatePayload(value: unknown): { transcriptsCsv: string; annotationsCsv: string; transcripts: Transcript[]; annotations: Annotation[] } {
|
||||
const payload = validateRow(value, "提交内容");
|
||||
if (!Array.isArray(payload.transcripts) || !Array.isArray(payload.annotations)) {
|
||||
throw new WorkspaceError("提交内容必须包含 transcripts 和 annotations 两个数组。");
|
||||
}
|
||||
payload.transcripts.forEach((value, index) => {
|
||||
const label = `转写第 ${index + 1} 行`;
|
||||
const row = validateRow(value, label);
|
||||
for (const name of ["call_id", "role", "transcript"]) requireString(row, name, label);
|
||||
for (const name of ["start_ms", "end_ms"]) requireMillis(row, name, label);
|
||||
if (row.role !== "agent" && row.role !== "user") throw new WorkspaceError(`${label} 的 role 必须是 agent 或 user。`);
|
||||
});
|
||||
payload.annotations.forEach((value, index) => {
|
||||
const label = `EOT 第 ${index + 1} 行`;
|
||||
const row = validateRow(value, label);
|
||||
for (const name of ["call_id", "user_text_at_decision", "can_reply_now", "confidence", "reason", "annotator"]) {
|
||||
requireString(row, name, label);
|
||||
}
|
||||
requireMillis(row, "decision_ms", label);
|
||||
if (!["", "0", "1"].includes(String(row.can_reply_now))) throw new WorkspaceError(`${label} 的 can_reply_now 必须是 0、1 或空。`);
|
||||
if (!["", "high", "medium", "low"].includes(String(row.confidence))) throw new WorkspaceError(`${label} 的 confidence 取值无效。`);
|
||||
});
|
||||
try {
|
||||
const transcriptsCsv = exportTranscripts(payload.transcripts as Transcript[]);
|
||||
const annotationsCsv = exportAnnotations(payload.annotations as Annotation[]);
|
||||
return {
|
||||
transcriptsCsv,
|
||||
annotationsCsv,
|
||||
transcripts: parseTranscripts(transcriptsCsv),
|
||||
annotations: parseAnnotations(annotationsCsv),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new WorkspaceError(error instanceof Error ? error.message : "数据格式有误。");
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceCsvPair(transcriptsCsv: string, annotationsCsv: string): Promise<void> {
|
||||
const token = `${process.pid}-${randomUUID()}`;
|
||||
const entries = [
|
||||
{ name: "asr_history.csv", content: transcriptsCsv },
|
||||
{ name: "eot_labels.csv", content: annotationsCsv },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
destination: path.join(savedRoot, entry.name),
|
||||
temporary: path.join(savedRoot, `.${entry.name}.${token}.tmp`),
|
||||
backup: path.join(savedRoot, `.${entry.name}.${token}.backup`),
|
||||
existed: false,
|
||||
replaced: false,
|
||||
}));
|
||||
let restored = true;
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
entry.existed = await exists(entry.destination);
|
||||
if (entry.existed) await fs.copyFile(entry.destination, entry.backup);
|
||||
await fs.writeFile(entry.temporary, entry.content, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
||||
}
|
||||
for (const entry of entries) {
|
||||
await fs.rename(entry.temporary, entry.destination);
|
||||
entry.replaced = true;
|
||||
}
|
||||
} catch (error) {
|
||||
for (const entry of entries.filter((entry) => entry.replaced)) {
|
||||
try {
|
||||
if (entry.existed) await fs.rename(entry.backup, entry.destination);
|
||||
else await fs.unlink(entry.destination);
|
||||
} catch (rollbackError) {
|
||||
restored = false;
|
||||
console.error("EOT workspace rollback failed; backup retained", entry.backup, rollbackError);
|
||||
}
|
||||
}
|
||||
if (!restored) throw new WorkspaceError("保存未完成,恢复备份时发生错误。请检查工作数据目录中的 backup 文件。", 500);
|
||||
throw error;
|
||||
} finally {
|
||||
for (const entry of entries) {
|
||||
await fs.rm(entry.temporary, { force: true });
|
||||
if (restored) await fs.rm(entry.backup, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
try {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
throw new WorkspaceError("仅允许当前工具页面保存工作数据。", 403);
|
||||
}
|
||||
const raw = await request.text();
|
||||
if (Buffer.byteLength(raw, "utf8") > maxPayloadBytes) throw new WorkspaceError("提交数据超过 20 MB,请分批整理。", 413);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new WorkspaceError("提交内容不是有效 JSON。", 400);
|
||||
}
|
||||
const validated = validatePayload(payload);
|
||||
return await serialize(async () => {
|
||||
await fs.mkdir(savedRoot, { recursive: true });
|
||||
return await withDiskLock(async () => {
|
||||
await replaceCsvPair(validated.transcriptsCsv, validated.annotationsCsv);
|
||||
return json({
|
||||
transcripts: validated.transcripts,
|
||||
annotations: validated.annotations,
|
||||
source: "saved",
|
||||
savedAt: await savedAt(),
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f6f7f0;
|
||||
--surface: #fffefb;
|
||||
--surface-soft: #fafbf5;
|
||||
--text: #343d31;
|
||||
--muted: #74816a;
|
||||
--border: #e2e7d9;
|
||||
--sage: #718761;
|
||||
--sage-dark: #526a42;
|
||||
--orange: #d66d40;
|
||||
--orange-dark: #b95329;
|
||||
--orange-light: #fbede4;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body { background: var(--background); color: var(--text); font-family: var(--font-sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: default; }
|
||||
button:disabled { opacity: .45; }
|
||||
button, input, select, textarea { color: inherit; }
|
||||
button { line-height: 1.4; }
|
||||
a { color: inherit; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid #82956d; outline-offset: 3px; }
|
||||
input, select, textarea { border: 1px solid #dfe5d5; border-radius: 7px; background: #fffefb; }
|
||||
input, select { min-height: 36px; padding: 7px 10px; }
|
||||
textarea { min-height: 82px; padding: 10px 11px; resize: vertical; line-height: 1.7; }
|
||||
input::placeholder, textarea::placeholder { color: #748167; }
|
||||
input[type="checkbox"], input[type="radio"] { min-height: auto; width: 15px; height: 15px; padding: 0; accent-color: var(--sage); }
|
||||
input[type="range"] { padding: 0; }
|
||||
input:disabled, select:disabled, textarea:disabled { background: #f3f5ed; color: #7d8773; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #9eaf8c; }
|
||||
svg { flex-shrink: 0; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1, h2, h3 { font-weight: 650; }
|
||||
.muted { color: var(--muted); }
|
||||
.eyebrow { color: #748167; font-size: 11px; font-weight: 650; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.app-shell { min-height: 100vh; }
|
||||
|
||||
.topbar { position: relative; z-index: 40; display: flex; align-items: center; gap: 22px; min-height: 70px; padding: 14px 25px; border-bottom: 1px solid var(--border); background: #fffefb; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: #36432e; font-size: 16px; font-weight: 700; white-space: nowrap; letter-spacing: -.035em; text-decoration: none; }
|
||||
.brand > span:last-child { display: block; line-height: 1.25; }
|
||||
.brand small { display: block; margin-top: 4px; color: #879574; font-size: 11px; font-weight: 400; letter-spacing: .015em; }
|
||||
.brand-mark { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 9px; background: var(--orange); color: #fff8ed; }
|
||||
.brand-mark svg { width: 18px; height: 18px; }
|
||||
.brand-light { display: inline; margin-left: 3px; font-weight: 400; color: #7b886c; letter-spacing: -.02em; }
|
||||
.workspace-tag { display: flex; align-items: center; gap: 7px; padding: 5px 9px; border: 1px solid #e3e8d9; border-radius: 5px; color: #758265; background: #f8faf2; font-size: 11px; white-space: nowrap; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #8aa36f; }
|
||||
.status-dot.orange { background: var(--orange); }
|
||||
.top-actions { display: flex; align-items: center; gap: 11px; margin-left: auto; }
|
||||
.save-status { display: flex; align-items: center; gap: 6px; color: #748269; font-size: 11px; white-space: nowrap; }
|
||||
.save-status svg { width: 13px; height: 13px; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 7px 12px; border: 1px solid #dfe5d5; border-radius: 7px; background: var(--surface); color: #5d7050; font-size: 12px; font-weight: 550; white-space: nowrap; transition: background .15s, border-color .15s; }
|
||||
.button svg { width: 15px; height: 15px; }
|
||||
.button:hover:not(:disabled) { background: #f0f4e8; border-color: #c8d4b7; }
|
||||
.button.primary { background: var(--orange); border-color: var(--orange); color: #fffaf3; }
|
||||
.button.primary:hover:not(:disabled) { background: #c66035; border-color: #c66035; }
|
||||
.button.ghost { background: transparent; border-color: transparent; color: #738465; }
|
||||
.button.ghost:hover:not(:disabled) { background: #edf2e5; border-color: transparent; }
|
||||
.button.subtle { background: #f4f7ed; color: #6a7f58; }
|
||||
.button.small { min-height: 28px; padding: 5px 9px; border-radius: 6px; font-size: 11px; }
|
||||
.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 6px; background: transparent; color: #748167; }
|
||||
.icon-button:hover:not(:disabled) { background: #edf2e4; color: #596f46; }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
.menu-wrap { position: relative; }
|
||||
.menu-scrim { position: fixed; inset: 70px 0 0; z-index: 30; border: 0; background: transparent; cursor: default; }
|
||||
.dropdown { position: absolute; z-index: 31; right: 0; top: calc(100% + 8px); min-width: 196px; padding: 6px; border: 1px solid #dfe6d4; border-radius: 10px; background: #fffefb; box-shadow: 0 10px 28px #33452316; }
|
||||
.dropdown button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 10px 9px; text-align: left; border: 0; border-radius: 6px; background: transparent; color: #586d48; font-size: 12px; }
|
||||
.dropdown button:hover { background: #eff4e6; }
|
||||
.dropdown svg { width: 16px; height: 16px; }
|
||||
.dropdown-divider { height: 1px; margin: 5px 7px; background: var(--border); }
|
||||
|
||||
.workspace-grid { display: grid; grid-template-columns: 236px minmax(0, 1fr) 326px; align-items: stretch; min-height: calc(100vh - 70px); }
|
||||
.sidebar { display: flex; flex-direction: column; min-width: 0; padding: 23px 16px 17px; border-right: 1px solid var(--border); background: #fbfcf6; }
|
||||
.sidebar-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 7px; margin-bottom: 16px; }
|
||||
.sidebar-heading h2 { margin: 0; font-size: 14px; font-weight: 650; }
|
||||
.count-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 23px; padding: 1px 6px; border: 1px solid #e3e9d8; border-radius: 5px; background: #f1f5e9; color: #748167; font-family: var(--font-mono); font-size: 11px; }
|
||||
.dataset-scope { display: flex; flex-direction: column; gap: 6px; margin: 0 4px 15px; }
|
||||
.dataset-scope > span { color: #718362; font-size: 11px; font-weight: 550; }
|
||||
.dataset-scope select { width: 100%; min-width: 0; min-height: 36px; padding: 7px 9px; border-color: #dce5cd; background: #f4f8eb; color: #61794b; font-size: 12px; }
|
||||
.dataset-scope > small { color: #879578; font-size: 11px; line-height: 1.5; }
|
||||
.search-box { display: flex; align-items: center; gap: 7px; margin: 0 4px; padding: 0 10px; border: 1px solid #e0e6d5; border-radius: 7px; background: #fffefb; }
|
||||
.search-box svg { width: 14px; height: 14px; color: #748167; }
|
||||
.search-box input { min-width: 0; width: 100%; min-height: 34px; padding: 7px 0; border: 0; background: transparent; outline: none; font-size: 11px; }
|
||||
.search-box:focus-within { outline: 2px solid #a4b392; outline-offset: 2px; }
|
||||
.sidebar-tabs { display: flex; gap: 2px; padding: 17px 5px 10px; border-bottom: 1px solid #e5eadc; }
|
||||
.sidebar-tabs button { flex: 1; padding: 5px 1px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #748167; font-size: 11px; }
|
||||
.sidebar-tabs button.active { border-bottom-color: var(--orange); color: #b76a40; font-weight: 650; }
|
||||
.call-list { display: flex; flex-direction: column; gap: 5px; margin: 12px -3px 22px; }
|
||||
.call-item { position: relative; display: flex; align-items: center; gap: 9px; min-width: 0; width: 100%; padding: 12px 10px; border: 1px solid transparent; border-radius: 9px; background: transparent; text-align: left; }
|
||||
.call-item:hover { background: #f0f4e7; }
|
||||
.call-item.selected { border-color: #dfe7ce; background: #edf3e2; }
|
||||
.call-icon { display: grid; place-items: center; width: 31px; height: 34px; border: 1px solid #e2e8d7; border-radius: 6px; background: #f9fbf3; color: #748167; }
|
||||
.call-icon svg { width: 15px; height: 15px; }
|
||||
.selected .call-icon { border-color: #d7e2c5; color: #748a5f; background: #f6faed; }
|
||||
.call-item-copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
|
||||
.call-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #586b47; font-size: 11px; font-weight: 600; }
|
||||
.call-item-copy span, .call-item-copy small { color: #748167; font-size: 11px; }
|
||||
.call-item-copy em { color: #a97b50; font-style: normal; }
|
||||
.selected-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--orange); }
|
||||
.sidebar-bottom { margin-top: auto; padding: 18px 7px 0; border-top: 1px solid #e4eadb; font-size: 11px; color: #748167; }
|
||||
.sidebar-bottom > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 7px; }
|
||||
.sidebar-bottom strong { font-family: var(--font-mono); font-weight: 500; font-size: 11px; color: #72845c; }
|
||||
.progress-track { height: 5px; overflow: hidden; margin: 10px 0 19px; border-radius: 3px; background: #e7eddf; }
|
||||
.progress-track > span, .progress-track > div { display: block; height: 100%; border-radius: 3px; background: #99ad80; }
|
||||
.help-link { display: flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: #748167; font-size: 11px; }
|
||||
.help-link svg { width: 14px; height: 14px; }
|
||||
.loading-label { padding: 15px 8px; color: #748167; font-size: 12px; }
|
||||
|
||||
.main-workspace { min-width: 0; padding: 28px 28px 17px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 21px; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: #748167; font-size: 11px; }
|
||||
.breadcrumb svg { width: 12px; height: 12px; }
|
||||
.page-heading h1 { margin: 0; color: #3e4e31; font-size: 22px; line-height: 1.35; letter-spacing: -.035em; }
|
||||
.page-heading p { margin: 6px 0 0; color: #748167; font-size: 11px; }
|
||||
.offline-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; border: 1px solid #e0e6d6; border-radius: 5px; background: #f5f8ef; color: #748167; font-size: 11px; }
|
||||
.page-heading .offline-badge { vertical-align: middle; margin-left: 10px; letter-spacing: 0; font-weight: 400; }
|
||||
.view-switch { display: flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid #e0e6d5; border-radius: 8px; background: #edf2e5; }
|
||||
.view-switch button { display: flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 11px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #748167; font-size: 11px; white-space: nowrap; }
|
||||
.view-switch button svg { width: 13px; height: 13px; }
|
||||
.view-switch button.active { background: #fffefb; border-color: #dfe6d3; color: #657d4e; box-shadow: 0 1px 2px #40582a08; }
|
||||
.panel { min-width: 0; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 19px; border-bottom: 1px solid #e8ecdf; }
|
||||
.panel-heading h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 14px; font-weight: 650; color: #52663f; }
|
||||
.panel-heading h2 svg { width: 16px; height: 16px; color: #748167; }
|
||||
.panel-heading .muted { font-size: 11px; }
|
||||
.panel-heading p { margin: 5px 0 0; color: #748167; font-size: 11px; }
|
||||
.audio-filename { display: inline-flex; align-items: center; gap: 6px; overflow: hidden; min-width: 0; color: #748167; font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.audio-filename svg { width: 14px; height: 14px; color: #748167; }
|
||||
.player { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 15px 18px; }
|
||||
.player-main { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.play-button { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 1px solid #d8e3c8; border-radius: 50%; background: #eff5e5; color: #7d965e; }
|
||||
.play-button svg { width: 14px; height: 14px; }
|
||||
.play-button:hover:not(:disabled) { background: #e2edce; }
|
||||
.time-display { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: #748167; font-variant-numeric: tabular-nums; }
|
||||
.time-display strong { color: #657d4f; font-weight: 600; }
|
||||
.player-secondary { display: flex; align-items: center; gap: 12px; }
|
||||
.speed-label { display: flex; align-items: center; gap: 6px; color: #748167; font-size: 11px; }
|
||||
.speed-label select { min-height: 28px; padding: 3px 6px; border-color: #e1e8d7; background: #fafcf5; font-size: 11px; color: #7b9067; }
|
||||
.timeline-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin: 10px 0 8px; padding: 0 4px; color: #748167; font-size: 11px; }
|
||||
.timeline-legend > span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.legend-dot { width: 6px; height: 6px; border-radius: 2px; }
|
||||
.legend-dot.agent { background: #a6b68e; }
|
||||
.legend-dot.user { background: #cba577; }
|
||||
.legend-dot.decision { background: var(--orange); }
|
||||
.legend-line { width: 12px; border-top: 1px dashed var(--orange); }
|
||||
.timeline-tip { display: flex; align-items: center; gap: 6px; padding: 0 4px; color: #748167; font-size: 11px; }
|
||||
.timeline-legend .timeline-tip { margin-left: auto; }
|
||||
.timeline-tip svg { width: 13px; height: 13px; }
|
||||
|
||||
.transcript-panel { margin-top: 18px; }
|
||||
.transcript-actions { display: flex; align-items: center; gap: 11px; }
|
||||
.toggle-label { display: inline-flex; align-items: center; gap: 6px; color: #748167; font-size: 11px; cursor: pointer; }
|
||||
.toggle-label input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.toggle-track { position: relative; width: 26px; height: 15px; border-radius: 8px; background: #dce5d1; transition: background .15s; }
|
||||
.toggle-track::after { content: ""; position: absolute; left: 3px; top: 3px; width: 9px; height: 9px; border-radius: 50%; background: #fffefb; transition: transform .15s; }
|
||||
.toggle-label input:checked + .toggle-track { background: #91a977; }
|
||||
.toggle-label input:checked + .toggle-track::after { transform: translateX(11px); }
|
||||
.toggle-label input:focus-visible + .toggle-track { outline: 2px solid #7b9366; outline-offset: 3px; }
|
||||
.transcript-list { max-height: 443px; overflow-y: auto; padding: 6px 19px 13px; scrollbar-width: thin; scrollbar-color: #d3ddc6 transparent; }
|
||||
.transcript-row { position: relative; display: flex; align-items: flex-start; gap: 10px; padding: 16px 0; border-bottom: 1px solid #edf0e6; }
|
||||
.transcript-row:last-child { border-bottom: 0; }
|
||||
.transcript-row.future { color: #748167; }
|
||||
.transcript-row.future .transcript-content p { color: #748167; }
|
||||
.transcript-row.playing-row::before { content: ""; position: absolute; left: -12px; top: 17px; bottom: 17px; width: 2px; border-radius: 2px; background: #a4b78c; }
|
||||
.speaker-avatar { display: grid; place-items: center; width: 28px; height: 28px; margin-top: 1px; border: 1px solid #dfe7d1; border-radius: 8px; background: #f0f5e7; color: #748167; }
|
||||
.speaker-avatar svg { width: 14px; height: 14px; }
|
||||
.speaker-avatar.user { background: #faf0e3; border-color: #ecddc5; color: #b09268; }
|
||||
.transcript-content { min-width: 0; flex: 1; }
|
||||
.speaker-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-bottom: 6px; color: #7e916b; font-size: 11px; }
|
||||
.speaker-meta strong { color: #6f865b; font-size: 11px; font-weight: 600; }
|
||||
.transcript-time { padding: 0; border: 0; background: transparent; color: #748167; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
button.transcript-time:hover { color: #6c8455; text-decoration: underline; }
|
||||
.transcript-content p { margin: 0; color: #586e44; font-size: 13px; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
.transcript-content textarea { width: 100%; min-height: 68px; font-size: 13px; }
|
||||
.future-tag { padding: 1px 5px; border: 1px solid #e1e7d7; border-radius: 4px; color: #748167; font-size: 11px; }
|
||||
.row-tools { display: flex; align-items: center; gap: 2px; flex-shrink: 0; margin-left: auto; opacity: .65; }
|
||||
.transcript-row:hover .row-tools, .transcript-row:focus-within .row-tools { opacity: 1; }
|
||||
.row-tools .icon-button { width: 24px; height: 24px; }
|
||||
.row-tools .icon-button svg { width: 13px; height: 13px; }
|
||||
.text-insert { display: inline-flex; align-items: center; gap: 4px; padding: 4px 6px; border: 1px solid #e5e9dc; border-radius: 5px; background: #f8fbf1; color: #748167; font-size: 11px; white-space: nowrap; }
|
||||
.text-insert svg { width: 11px; height: 11px; }
|
||||
.text-insert:hover { border-color: #c8d8b4; color: #6f8b53; }
|
||||
.hidden-history { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 7px; width: 100%; padding: 12px 12px; border: 0; border-top: 1px solid #edf0e4; background: #fafcf4; color: #748167; text-align: center; font-size: 11px; }
|
||||
.hidden-history > span { color: #7a915f; }
|
||||
.current-text-preview { margin: 12px 19px 16px; padding: 11px 13px; border: 1px solid #eadbc5; border-radius: 7px; background: #fcf6ed; color: #748167; font-size: 12px; line-height: 1.75; }
|
||||
.current-text-preview > span, .current-text-preview > strong { display: block; margin-bottom: 3px; color: #b29268; font-size: 11px; font-weight: 500; }
|
||||
.current-text-preview p { margin: 2px 0 5px; color: #8f734e; font-size: 12px; }
|
||||
.current-text-preview small { color: #9d8768; font-size: 11px; }
|
||||
.arbitration-note { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border: 1px solid #e7dcc5; border-radius: 7px; background: #fbf6e9; color: #748167; font-size: 11px; }
|
||||
.arbitration-note svg { width: 14px; height: 14px; }
|
||||
.empty-transcript { padding: 32px 16px; text-align: center; color: #748167; font-size: 12px; }
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
.editor-heading h2 { margin: 0; color: #526943; font-size: 14px; }
|
||||
.editor-heading p { margin: 5px 0 0; color: #748167; font-size: 11px; }
|
||||
.editor-number { display: inline-flex; align-items: center; gap: 4px; padding: 4px 7px; border: 1px solid #e0e8d5; border-radius: 5px; background: #f5f9ef; color: #748167; font-family: var(--font-mono); font-size: 11px; }
|
||||
.editor-body { padding: 18px 21px 19px; }
|
||||
.decision-card { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 13px 14px 10px; margin-bottom: 17px; border: 1px solid #eed8c4; border-radius: 8px; background: #fcf1e7; }
|
||||
.decision-card > div { min-width: 0; }
|
||||
.decision-card > span { display: flex; align-items: center; gap: 6px; color: #a38361; font-size: 11px; }
|
||||
.decision-card strong { display: block; margin-top: 3px; color: #b27546; font-family: var(--font-mono); font-size: 22px; font-weight: 500; letter-spacing: -.035em; font-variant-numeric: tabular-nums; }
|
||||
.decision-card .button { color: #ae875e; border-color: #e9d6bf; background: #fff8f0; }
|
||||
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; padding-top: 9px; border-top: 1px solid #efdccb; }
|
||||
.review-actions span { color: #748167; font-size: 11px; }
|
||||
.review-actions button { display: flex; align-items: center; gap: 5px; padding: 3px 0; border: 0; background: none; color: #a5805f; font-size: 11px; }
|
||||
.review-actions button:hover:not(:disabled) { color: #b26335; }
|
||||
.label-fieldset { display: flex; flex-direction: column; gap: 7px; margin: 0 0 18px; padding: 0; border: 0; }
|
||||
.label-fieldset legend { margin-bottom: 9px; padding: 0; color: #7c8f6b; font-size: 11px; }
|
||||
.label-choice { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 57px; padding: 9px 11px; border: 1px solid #e1e8d6; border-radius: 7px; background: #fbfdf7; color: #748167; font-size: 11px; text-align: left; }
|
||||
.label-choice > span:nth-child(2) { flex: 1; }
|
||||
.label-choice strong { display: block; font-size: 12px; font-weight: 600; }
|
||||
.label-choice small { display: block; margin-top: 3px; color: #7a886b; font-size: 11px; font-weight: 400; }
|
||||
.label-choice kbd { margin-left: auto; }
|
||||
.label-choice:hover:not(:disabled) { background: #f0f5e7; border-color: #bdcda9; }
|
||||
.label-choice.positive.selected, .label-choice.positive.active, .label-choice.positive[aria-pressed="true"] { background: #ebf3de; border-color: #b8cb9a; color: #75954c; }
|
||||
.label-choice.negative.selected, .label-choice.negative.active, .label-choice.negative[aria-pressed="true"] { background: #fbede3; border-color: #dfb590; color: #b47a4c; }
|
||||
.label-choice.pending.selected, .label-choice.pending.active, .label-choice.pending[aria-pressed="true"] { background: #f1f4e8; border-color: #bdcaa9; color: #748167; }
|
||||
.choice-symbol { display: grid; place-items: center; width: 21px; height: 21px; border: 1px solid #dee5d4; border-radius: 50%; font-family: var(--font-mono); font-size: 11px; }
|
||||
.label-choice.positive .choice-symbol { border-color: #c7d6b2; color: #7e9a60; }
|
||||
.label-choice.negative .choice-symbol { border-color: #e6cbb1; color: #b88c65; }
|
||||
.label-choice.pending .choice-symbol { color: #748167; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; }
|
||||
.form-field > label, .form-field > span { display: flex; align-items: center; justify-content: space-between; gap: 7px; color: #7e916b; font-size: 11px; }
|
||||
.form-field > label > span { color: #748167; font-size: 11px; font-weight: 400; }
|
||||
.form-field input, .form-field select, .form-field textarea { width: 100%; min-width: 0; font-size: 12px; color: #6b8356; }
|
||||
.form-field textarea { background: #fdfef9; }
|
||||
.form-field > small, .form-field > span > small { color: #829174; font-size: 11px; line-height: 1.6; }
|
||||
.form-two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field-error { margin: -1px 0 8px; color: #b5704a; font-size: 11px; }
|
||||
.editor-bottom { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; padding-top: 15px; border-top: 1px solid #e8edde; }
|
||||
.editor-bottom > span { display: inline-flex; align-items: center; gap: 6px; color: #7f916e; font-size: 11px; }
|
||||
.delete-button { display: flex; align-items: center; gap: 5px; padding: 5px 0; border: 0; background: transparent; color: #a9977d; font-size: 11px; }
|
||||
.delete-button svg { width: 13px; height: 13px; }
|
||||
.delete-button:hover:not(:disabled) { color: #b77148; }
|
||||
.mini-dot { display: inline-block; flex-shrink: 0; width: 5px; height: 5px; border-radius: 50%; background: #a6b68e; }
|
||||
.mini-dot.positive { background: #8ba565; }
|
||||
.mini-dot.negative { background: #d1a173; }
|
||||
.mini-dot.pending { background: #b1b99d; }
|
||||
.empty-editor { padding: 32px 22px; color: #748167; text-align: center; }
|
||||
.empty-editor h3 { font-size: 14px; color: #728b5a; }
|
||||
.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; }
|
||||
.keyboard-footer > div { display: flex; align-items: center; flex-wrap: wrap; gap: 18px; }
|
||||
.keyboard-footer > div > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.keyboard-footer button, .toast button { display: inline-flex; align-items: center; justify-content: center; padding: 3px; border: 0; background: transparent; color: #849576; }
|
||||
kbd { min-width: 17px; padding: 0 4px; border: 1px solid #dce5ce; border-bottom-width: 2px; border-radius: 4px; background: #fbfdf7; color: #748167; font-family: var(--font-mono); font-size: 11px; text-align: center; }
|
||||
|
||||
.toast { position: fixed; z-index: 60; left: 50%; bottom: 25px; display: flex; align-items: center; gap: 8px; max-width: calc(100vw - 32px); padding: 11px 16px; transform: translateX(-50%); border: 1px solid #d9e4c9; border-radius: 9px; background: #fffefb; color: #667e50; box-shadow: 0 7px 25px #35482216; font-size: 12px; }
|
||||
.toast.success { border-color: #c9dcb5; }
|
||||
.toast.error { border-color: #e5c6ab; color: #af7550; }
|
||||
.toast.info { border-color: #dbe3ce; }
|
||||
.toast svg { width: 16px; height: 16px; }
|
||||
.modal-backdrop { position: fixed; z-index: 50; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: #35452b52; backdrop-filter: blur(2px); }
|
||||
.modal { width: 100%; max-width: 470px; max-height: calc(100vh - 48px); overflow-y: auto; padding: 24px; border: 1px solid #dfe7d3; border-radius: 16px; background: #fffefb; box-shadow: 0 22px 60px #26341d25; }
|
||||
.modal-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.modal-heading h2 { margin: 0; color: #536d3e; font-size: 17px; }
|
||||
.modal > p { color: #7b9168; font-size: 13px; line-height: 1.8; }
|
||||
.modal-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
.help-modal { max-width: 560px; }
|
||||
.shortcut-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 17px 0; }
|
||||
.shortcut-grid > div, .shortcut-grid > span { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 11px; border: 1px solid #e2e9d6; border-radius: 7px; background: #f8fbf1; color: #7f956c; font-size: 11px; }
|
||||
.shortcut-grid kbd { font-size: 11px; }
|
||||
.help-modal ul { margin: 15px 0 0; padding-left: 20px; color: #7b9168; font-size: 12px; line-height: 1.9; }
|
||||
.help-modal ol { margin: 15px 0; padding-left: 20px; color: #6c8256; font-size: 12px; line-height: 1.9; }
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.main-workspace { padding-inline: 42px; }
|
||||
.transcript-list { max-height: 480px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.topbar { gap: 14px; }
|
||||
.save-status { display: none; }
|
||||
.player-secondary { gap: 7px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.workspace-grid { grid-template-columns: 210px minmax(0, 1fr); }
|
||||
.sidebar { grid-row: span 2; }
|
||||
.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 (max-width: 720px) {
|
||||
.topbar { min-height: 65px; padding: 12px 15px; gap: 9px; flex-wrap: wrap; }
|
||||
.brand { gap: 7px; font-size: 15px; }
|
||||
.brand-mark { width: 28px; height: 28px; border-radius: 8px; }
|
||||
.top-actions { gap: 5px; }
|
||||
.top-actions .button { min-height: 31px; padding: 6px 8px; font-size: 11px; }
|
||||
.top-actions .button svg { width: 13px; height: 13px; }
|
||||
.workspace-grid { display: flex; flex-direction: column; min-height: calc(100vh - 65px); }
|
||||
.sidebar { padding: 17px 15px 13px; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-heading { margin-bottom: 12px; }
|
||||
.search-box { margin: 0; }
|
||||
.sidebar-tabs { padding: 9px 0 0; }
|
||||
.sidebar-tabs button { padding-block: 7px; }
|
||||
.call-list { display: flex; flex-direction: row; overflow-x: auto; gap: 7px; margin: 11px 0 0; padding-bottom: 3px; scrollbar-width: thin; }
|
||||
.call-item { width: 210px; flex-shrink: 0; padding: 10px 9px; border-color: #e2e8d6; }
|
||||
.sidebar-bottom { display: none; }
|
||||
.main-workspace { padding: 21px 15px 18px; }
|
||||
.page-heading { gap: 12px; margin-bottom: 17px; }
|
||||
.page-heading h1 { font-size: 19px; }
|
||||
.page-heading p { font-size: 11px; }
|
||||
.view-switch button { padding: 5px 8px; font-size: 11px; }
|
||||
.panel-heading { padding: 13px 14px; }
|
||||
.panel-heading h2 { font-size: 14px; }
|
||||
.audio-filename { max-width: 170px; font-size: 11px; }
|
||||
.player { padding: 12px 13px; flex-wrap: wrap; }
|
||||
.player-main { gap: 7px; }
|
||||
.time-display { gap: 4px; font-size: 11px; }
|
||||
.speed-label { gap: 4px; font-size: 11px; }
|
||||
.speed-label select { font-size: 11px; }
|
||||
.player-secondary > .button { min-height: 28px; padding: 4px 7px; font-size: 11px; }
|
||||
.timeline-legend { gap: 10px; font-size: 11px; }
|
||||
.transcript-actions { gap: 7px; }
|
||||
.transcript-list { max-height: 420px; padding-inline: 14px; }
|
||||
.transcript-row { gap: 8px; }
|
||||
.transcript-content p { font-size: 12px; }
|
||||
.row-tools { flex-direction: column; }
|
||||
.text-insert { font-size: 11px; padding: 3px 4px; }
|
||||
.toggle-label { font-size: 11px; }
|
||||
.current-text-preview { margin-inline: 14px; }
|
||||
.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; }
|
||||
.modal { padding: 20px; max-height: calc(100vh - 28px); }
|
||||
.shortcut-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.brand-light { display: none; }
|
||||
.top-actions { gap: 3px; }
|
||||
.top-actions .button { padding-inline: 7px; }
|
||||
.page-heading h1 { max-width: 180px; overflow-wrap: anywhere; }
|
||||
.player-secondary { width: 100%; justify-content: space-between; padding-left: 41px; }
|
||||
.transcript-actions .button { padding-inline: 5px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "EOT Studio · 话轮标注工作台",
|
||||
description: "在时间轴上回听录音、查看对话,编辑与管理 EOT 标注。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Workbench from "./workbench";
|
||||
|
||||
export default function Home() {
|
||||
return <Workbench />;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import Timeline from "@/components/timeline";
|
||||
import CallReferencePanel from "@/components/call-reference";
|
||||
import type { CallReference } from "@/lib/call-reference";
|
||||
import { createAnnotation, exportAnnotations, exportTranscripts, formatTime, parseAnnotations, parseTranscripts, reasonOptions, validateAnnotation, type Annotation, type Transcript } from "@/lib/annotations";
|
||||
|
||||
type Recording = { call_id: string; name: string; path: string; url: string };
|
||||
type Dataset = { transcripts: Transcript[]; annotations: Annotation[] };
|
||||
type Workspace = Dataset & { recordings: Recording[]; callReferences: CallReference[]; source: "saved" | "example"; savedAt?: string };
|
||||
type Notice = { message: string; kind: "success" | "error" | "info" };
|
||||
const DRAFT_KEY = "eot-workbench-v1";
|
||||
const DEFAULT_DATASET = "韩泰机器人抢话";
|
||||
const ALL_DATASETS = "all";
|
||||
function recordingDataset(recording: Recording) {
|
||||
const parts = recording.path.replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
return parts.length > 1 ? parts[0] : "已导入对话";
|
||||
}
|
||||
function callDataset(id: string, recordings: Recording[], originalGroups?: Map<string, string>) {
|
||||
const original = originalGroups?.get(id);
|
||||
if (original) return original;
|
||||
const recording = recordings.find(row => row.call_id === id);
|
||||
return recording ? recordingDataset(recording) : id === "demo" ? "标准示例" : "已导入对话";
|
||||
}
|
||||
type IconName = "wave" | "upload" | "download" | "save" | "search" | "play" | "pause" | "back" | "forward" | "plus" | "check" | "clock" | "help" | "edit" | "trash" | "undo" | "headphones" | "chevron" | "close" | "file";
|
||||
const paths: Record<IconName, string> = {
|
||||
wave: "M3 10v4m4-8v12m5-16v20m5-16v12m4-8v4", upload: "M12 16V3m-5 5 5-5 5 5M4 16v4h16v-4", download: "M12 3v13m-5-5 5 5 5-5M4 17v3h16v-3",
|
||||
save: "M5 3h13l3 3v15H3V3h2m2 0v6h10V3M7 21v-8h10v8", search: "M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0", play: "m9 5 11 7-11 7V5", pause: "M8 5v14m8-14v14",
|
||||
back: "M11 5 4 12l7 7M4 12h10a6 6 0 0 1 6 6", forward: "m13 5 7 7-7 7M20 12H10a6 6 0 0 0-6 6", plus: "M12 5v14M5 12h14", check: "m5 12 4 4L19 6", clock: "M12 7v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0",
|
||||
help: "M9 9a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0", edit: "m16 3 5 5-12 12-6 1 1-6L16 3m-2 2 5 5", trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
|
||||
undo: "M8 3 3 8l5 5M3 8h11a7 7 0 0 1 0 14", headphones: "M4 14v-2a8 8 0 0 1 16 0v2M4 13H2v7h5v-7H4m16 0h2v7h-5v-7h3", chevron: "m6 9 6 6 6-6", close: "m6 6 12 12M18 6 6 18", file: "M14 2H5v20h14V7l-5-5v5h5M8 12h8m-8 4h6",
|
||||
};
|
||||
function Icon({ name, size = 18 }: { name: IconName; size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={paths[name]} /></svg>; }
|
||||
function labelName(row: Annotation) { return row.can_reply_now === "1" ? "可以接话" : row.can_reply_now === "0" ? "继续等待" : row.reason ? "待仲裁" : "待标注"; }
|
||||
function labelClass(row: Annotation) { return row.can_reply_now === "1" ? "positive" : row.can_reply_now === "0" ? "negative" : "pending"; }
|
||||
function download(filename: string, content: string) { const url = URL.createObjectURL(new Blob(["\uFEFF", content], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
|
||||
|
||||
export default function Workbench() {
|
||||
const [transcripts, setTranscripts] = useState<Transcript[]>([]);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [recordings, setRecordings] = useState<Recording[]>([]);
|
||||
const [callReferences, setCallReferences] = useState<CallReference[]>([]);
|
||||
const [callId, setCallId] = useState("demo");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [currentMs, setCurrentMs] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [waveform, setWaveform] = useState<number[] | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [rate, setRate] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedTime, setSavedTime] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [datasetScope, setDatasetScope] = useState(DEFAULT_DATASET);
|
||||
const [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [showFuture, setShowFuture] = useState(false);
|
||||
const [menu, setMenu] = useState<"import" | "export" | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [undoStack, setUndoStack] = useState<Dataset[]>([]);
|
||||
const [transcriptDraft, setTranscriptDraft] = useState<{ index: number; row: Transcript } | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const asrImportRef = useRef<HTMLInputElement>(null);
|
||||
const eotImportRef = useRef<HTMLInputElement>(null);
|
||||
const audioImportRef = useRef<HTMLInputElement>(null);
|
||||
const playBoundary = useRef<number | null>(null);
|
||||
const objectUrls = useRef(new Set<string>());
|
||||
const versionRef = useRef(0);
|
||||
const originalGroupsRef = useRef(new Map<string, string>());
|
||||
|
||||
const notify = useCallback((message: string, kind: Notice["kind"] = "success") => setNotice({ message, kind }), []);
|
||||
const commit = useCallback((next: Dataset) => {
|
||||
versionRef.current++;
|
||||
setUndoStack(stack => [...stack.slice(-39), { transcripts, annotations }]);
|
||||
setTranscripts(next.transcripts); setAnnotations(next.annotations); setDirty(true); setSavedTime(null);
|
||||
}, [annotations, transcripts]);
|
||||
const updateAnnotation = useCallback((id: string, patch: Partial<Annotation>) => commit({ transcripts, annotations: annotations.map(row => row.id === id ? { ...row, ...patch } : row) }), [annotations, transcripts, commit]);
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/workspace", { cache: "no-store" }); const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "工作区读取失败");
|
||||
const workspace = payload as Workspace; let dataset: Dataset = { transcripts: workspace.transcripts, annotations: workspace.annotations }; let restored = false;
|
||||
try {
|
||||
const draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
|
||||
if (draft && (!workspace.savedAt || draft.updatedAt > Date.parse(workspace.savedAt))) {
|
||||
parseTranscripts(exportTranscripts(draft.transcripts)); parseAnnotations(exportAnnotations(draft.annotations));
|
||||
dataset = { transcripts: draft.transcripts, annotations: draft.annotations }; restored = true;
|
||||
}
|
||||
} catch { /* A corrupt browser draft never blocks opening the project. */ }
|
||||
originalGroupsRef.current = new Map(workspace.recordings.map(row => [row.call_id, recordingDataset(row)]));
|
||||
setTranscripts(dataset.transcripts); setAnnotations(dataset.annotations); setRecordings(workspace.recordings); setCallReferences(workspace.callReferences || []);
|
||||
const preferredRecording = workspace.recordings.find(row => recordingDataset(row) === DEFAULT_DATASET);
|
||||
const initialCall = preferredRecording?.call_id || dataset.annotations[0]?.call_id || dataset.transcripts[0]?.call_id || workspace.recordings[0]?.call_id || "demo";
|
||||
const initial = dataset.annotations.filter(row => row.call_id === initialCall).sort((a, b) => a.decision_ms - b.decision_ms)[0];
|
||||
setDatasetScope(preferredRecording ? DEFAULT_DATASET : ALL_DATASETS);
|
||||
setCallId(initialCall); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0);
|
||||
setDirty(restored); setSavedTime(workspace.savedAt || null); setInitialized(true);
|
||||
if (restored) notify("已恢复这个浏览器中尚未保存的草稿", "info");
|
||||
} catch (error) { notify(error instanceof Error ? error.message : "工作区读取失败", "error"); }
|
||||
finally { setLoading(false); }
|
||||
}, [notify]);
|
||||
useEffect(() => { void loadWorkspace(); }, [loadWorkspace]);
|
||||
useEffect(() => {
|
||||
if (!initialized || !dirty) return;
|
||||
const timer = setTimeout(() => { try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ transcripts, annotations, updatedAt: Date.now() })); } catch { notify("浏览器草稿空间不足,请保存到项目或导出 CSV", "error"); } }, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [transcripts, annotations, initialized, dirty, notify]);
|
||||
useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), notice.kind === "error" ? 9000 : 4500); return () => clearTimeout(timer); }, [notice]);
|
||||
useEffect(() => { const urls = objectUrls.current; return () => urls.forEach(url => URL.revokeObjectURL(url)); }, []);
|
||||
useEffect(() => {
|
||||
if (!transcriptDraft && !helpOpen) return;
|
||||
const oldFocus = document.activeElement as HTMLElement | null;
|
||||
const modal = document.querySelector<HTMLElement>(".modal"); const focusables = () => [...(modal?.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,textarea') || [])];
|
||||
(modal?.querySelector<HTMLElement>("[autofocus]") || focusables()[0])?.focus();
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { setTranscriptDraft(null); setHelpOpen(false); }
|
||||
if (event.key === "Tab") { const elements = focusables(); const first = elements[0], last = elements.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } }
|
||||
};
|
||||
document.addEventListener("keydown", handler); return () => { document.removeEventListener("keydown", handler); oldFocus?.focus(); };
|
||||
}, [!!transcriptDraft, helpOpen]);
|
||||
|
||||
const callAnnotations = useMemo(() => annotations.filter(row => row.call_id === callId).sort((a, b) => a.decision_ms - b.decision_ms), [annotations, callId]);
|
||||
const callTranscripts = useMemo(() => transcripts.map((row, index) => ({ row, index })).filter(({ row }) => row.call_id === callId).sort((a, b) => a.row.start_ms - b.row.start_ms), [transcripts, callId]);
|
||||
const selected = callAnnotations.find(row => row.id === selectedId) || null;
|
||||
const recording = recordings.find(row => row.call_id === callId) || null;
|
||||
const durationMs = audioDuration || Math.max(10000, ...callTranscripts.map(({ row }) => row.end_ms + 1500), ...callAnnotations.map(row => row.decision_ms + 1500));
|
||||
const boundaryMs = selected?.decision_ms ?? currentMs;
|
||||
const selectedError = selected ? validateAnnotation(selected, annotations.filter(row => row.id !== selected.id)) : null;
|
||||
const allCalls = useMemo(() => {
|
||||
const groupNumbers = new Map<string, number>();
|
||||
return [...new Set([...recordings.map(row => row.call_id), ...transcripts.map(row => row.call_id), ...annotations.map(row => row.call_id)])].map(id => {
|
||||
const recording = recordings.find(row => row.call_id === id); const rows = annotations.filter(row => row.call_id === id);
|
||||
const group = callDataset(id, recordings, originalGroupsRef.current);
|
||||
const groupNumber = (groupNumbers.get(group) || 0) + 1; groupNumbers.set(group, groupNumber);
|
||||
return { id, group, name: id === "demo" ? "示例对话" : recording ? `${group} · ${String(groupNumber).padStart(2, "0")}` : id, count: rows.length, pending: rows.filter(row => row.can_reply_now === "").length, recording };
|
||||
});
|
||||
}, [transcripts, annotations, recordings]);
|
||||
const datasetGroups = useMemo(() => [...new Set(allCalls.map(row => row.group))].sort((a, b) => a === DEFAULT_DATASET ? -1 : b === DEFAULT_DATASET ? 1 : a.localeCompare(b, "zh-CN")), [allCalls]);
|
||||
const scopedCalls = allCalls.filter(row => datasetScope === ALL_DATASETS || row.group === datasetScope);
|
||||
const scopedCallIds = new Set(scopedCalls.map(row => row.id));
|
||||
const scopedAnnotations = annotations.filter(row => scopedCallIds.has(row.call_id));
|
||||
const visibleCalls = scopedCalls.filter(row => `${row.id} ${row.name}`.toLowerCase().includes(search.toLowerCase()) && (filter !== "pending" || row.pending > 0 || row.count === 0));
|
||||
const callName = allCalls.find(row => row.id === callId)?.name || callId;
|
||||
const completed = scopedAnnotations.filter(row => row.can_reply_now !== "").length;
|
||||
const visibleTranscripts = callTranscripts.filter(({ row }) => showFuture || row.end_ms <= boundaryMs);
|
||||
const hiddenCount = callTranscripts.length - visibleTranscripts.length;
|
||||
|
||||
const seek = useCallback((ms: number) => {
|
||||
const next = Math.max(0, Math.min(durationMs, Math.round(ms))); setCurrentMs(next); playBoundary.current = null;
|
||||
if (audioRef.current && recording && Number.isFinite(audioRef.current.duration)) audioRef.current.currentTime = next / 1000;
|
||||
}, [durationMs, recording]);
|
||||
const selectAnnotation = useCallback((id: string) => { const row = annotations.find(row => row.id === id); if (row) { setSelectedId(id); seek(row.decision_ms); setShowFuture(false); } }, [annotations, seek]);
|
||||
const chooseCall = (id: string) => {
|
||||
if (id === callId) return;
|
||||
audioRef.current?.pause(); playBoundary.current = null; setPlaying(false); setAudioDuration(0); setWaveform(null);
|
||||
const initial = annotations.filter(row => row.call_id === id).sort((a, b) => a.decision_ms - b.decision_ms)[0];
|
||||
setCallId(id); setSelectedId(initial?.id || null); setCurrentMs(initial?.decision_ms || 0); setShowFuture(false); setTranscriptDraft(null);
|
||||
};
|
||||
const changeDatasetScope = (scope: string) => {
|
||||
setDatasetScope(scope); setSearch(""); setFilter("all");
|
||||
const first = allCalls.find(row => scope === ALL_DATASETS || row.group === scope);
|
||||
if (first) chooseCall(first.id);
|
||||
};
|
||||
const togglePlay = useCallback(async () => {
|
||||
const audio = audioRef.current; if (!recording || !audio) { notify("请先为这通对话关联录音", "info"); return; } playBoundary.current = null;
|
||||
if (audio.paused) { try { if (audio.ended) audio.currentTime = 0; await audio.play(); } catch { notify("录音暂时无法播放,请重新关联文件", "error"); } } else audio.pause();
|
||||
}, [recording, notify]);
|
||||
const review = async (future: boolean) => {
|
||||
if (!selected || !recording || !audioRef.current) { notify("关联录音后即可回听这个判断点", "info"); return; }
|
||||
const audio = audioRef.current; const start = future ? selected.decision_ms : Math.max(0, selected.decision_ms - 4000);
|
||||
audio.currentTime = Math.min(start, durationMs) / 1000; setCurrentMs(start); playBoundary.current = future ? Math.min(durationMs, start + 4000) : selected.decision_ms;
|
||||
if (future) setShowFuture(true); try { await audio.play(); } catch { notify("录音暂时无法播放", "error"); }
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!playing) return; let frame = 0;
|
||||
const tick = () => { const audio = audioRef.current; if (audio) { const position = audio.currentTime * 1000; if (playBoundary.current !== null && position >= playBoundary.current) { const stop = playBoundary.current; audio.pause(); audio.currentTime = stop / 1000; setCurrentMs(stop); playBoundary.current = null; return; } setCurrentMs(Math.round(position)); } frame = requestAnimationFrame(tick); };
|
||||
frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame);
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
const sameSource = !!recording && audio?.currentSrc === new URL(recording.url, window.location.href).href;
|
||||
const knownDuration = sameSource && audio && Number.isFinite(audio.duration) && audio.duration > 0 ? Math.round(audio.duration * 1000) : 0;
|
||||
setWaveform(null); setAudioDuration(knownDuration); setPlaying(false); if (!recording) return;
|
||||
const controller = new AbortController(); let context: AudioContext | null = null;
|
||||
void (async () => {
|
||||
try { const response = await fetch(recording.url, { signal: controller.signal }); if (!response.ok) throw new Error("无法读取录音"); const bytes = await response.arrayBuffer(); context = new AudioContext(); const buffer = await context.decodeAudioData(bytes); if (controller.signal.aborted) return;
|
||||
const samples = buffer.getChannelData(0), count = 640; const peaks = Array.from({ length: count }, (_, index) => { const begin = Math.floor(index * samples.length / count), end = Math.floor((index + 1) * samples.length / count); let peak = 0; for (let i = begin; i < end; i++) peak = Math.max(peak, Math.abs(samples[i])); return peak; });
|
||||
const max = Math.max(.01, ...peaks); setWaveform(peaks.map(peak => peak / max));
|
||||
} catch (error) { if (!controller.signal.aborted) notify(error instanceof Error ? error.message : "波形读取失败", "error"); } finally { if (context) void context.close(); }
|
||||
})(); return () => controller.abort();
|
||||
}, [recording?.url, notify]);
|
||||
useEffect(() => { if (audioRef.current) audioRef.current.playbackRate = rate; }, [rate, recording]);
|
||||
|
||||
const addAnnotation = useCallback(() => {
|
||||
if (mode === "view" || loading) return; const time = Math.round(currentMs); const existing = callAnnotations.find(row => row.decision_ms === time);
|
||||
if (existing) { notify("这个时间点已有标注,已为你选中", "info"); setSelectedId(existing.id); return; }
|
||||
const row = createAnnotation(callId, time); commit({ transcripts, annotations: [...annotations, row] }); setSelectedId(row.id); setShowFuture(false); notify(`已在 ${formatTime(time)} 新增判断点`);
|
||||
}, [mode, loading, currentMs, callAnnotations, callId, transcripts, annotations, commit, notify]);
|
||||
const setLabel = useCallback((label: Annotation["can_reply_now"]) => {
|
||||
if (!selected || mode === "view") return; const existing = reasonOptions.find(row => row.value === selected.reason && row.can_reply_now === label);
|
||||
updateAnnotation(selected.id, { can_reply_now: label, reason: existing?.value || (label === "1" ? "completed_turn" : label === "0" ? "mid_turn_pause" : "uncertain") });
|
||||
}, [selected, mode, updateAnnotation]);
|
||||
const undo = useCallback(() => {
|
||||
const previous = undoStack.at(-1); if (!previous) return;
|
||||
versionRef.current++; setTranscripts(previous.transcripts); setAnnotations(previous.annotations); setUndoStack(stack => stack.slice(0, -1)); setDirty(true);
|
||||
const ids = new Set([...previous.annotations.map(row => row.call_id), ...previous.transcripts.map(row => row.call_id), ...recordings.map(row => row.call_id)]);
|
||||
const firstInScope = [...ids].find(id => datasetScope === ALL_DATASETS || callDataset(id, recordings, originalGroupsRef.current) === datasetScope);
|
||||
const nextCall = ids.has(callId) ? callId : firstInScope || previous.annotations[0]?.call_id || previous.transcripts[0]?.call_id || recordings[0]?.call_id || "demo";
|
||||
const nextSelection = previous.annotations.find(row => row.id === selectedId && row.call_id === nextCall) || previous.annotations.find(row => row.call_id === nextCall);
|
||||
setSelectedId(nextSelection?.id || null); setShowFuture(false);
|
||||
if (nextCall !== callId) {
|
||||
const nextGroup = callDataset(nextCall, recordings, originalGroupsRef.current);
|
||||
if (datasetScope !== ALL_DATASETS && datasetScope !== nextGroup) setDatasetScope(nextGroup);
|
||||
setSearch(""); setFilter("all"); audioRef.current?.pause(); setCallId(nextCall); setCurrentMs(nextSelection?.decision_ms || 0); setAudioDuration(0);
|
||||
}
|
||||
notify("已撤销上一步修改", "info");
|
||||
}, [undoStack, selectedId, callId, recordings, datasetScope, notify]);
|
||||
const saveWorkspace = useCallback(async () => {
|
||||
if (saving) return; const version = versionRef.current;
|
||||
try { parseTranscripts(exportTranscripts(transcripts)); parseAnnotations(exportAnnotations(annotations)); if (audioDuration && callAnnotations.some(row => row.decision_ms > audioDuration)) throw new Error("当前判断点超出录音时长,请调整后保存"); setSaving(true);
|
||||
const response = await fetch("/api/workspace", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transcripts, annotations }) }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "保存失败");
|
||||
setSavedTime(payload.savedAt); if (versionRef.current === version) { setDirty(false); localStorage.removeItem(DRAFT_KEY); } notify(versionRef.current === version ? "已保存对话转写和 EOT 标注到项目" : "已保存,保存期间的新修改仍保留为草稿");
|
||||
} catch (error) { notify(error instanceof Error ? error.message : "保存失败", "error"); } finally { setSaving(false); }
|
||||
}, [saving, transcripts, annotations, audioDuration, callAnnotations, notify]);
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || helpOpen || transcriptDraft) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void saveWorkspace(); return; }
|
||||
const target = event.target as HTMLElement; if (target.closest("input, textarea, select, [contenteditable=true]") || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (event.code === "Space") { if (target.closest("button, a, summary")) return; event.preventDefault(); void togglePlay(); } else if (event.key === "ArrowLeft") { event.preventDefault(); seek(currentMs - 1000); } else if (event.key === "ArrowRight") { event.preventDefault(); seek(currentMs + 1000); }
|
||||
else if (event.key === "1") setLabel("1"); else if (event.key === "2") setLabel("0"); else if (event.key === "3") setLabel(""); else if (event.key.toLowerCase() === "a") addAnnotation();
|
||||
else if (event.key === "[" || event.key === "]") { const index = callAnnotations.findIndex(row => row.id === selectedId); const next = callAnnotations[index + (event.key === "[" ? -1 : 1)]; if (next) selectAnnotation(next.id); }
|
||||
}; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
|
||||
}, [currentMs, togglePlay, seek, setLabel, addAnnotation, selectedId, callAnnotations, selectAnnotation, saveWorkspace, helpOpen, transcriptDraft]);
|
||||
const importCsv = async (event: ChangeEvent<HTMLInputElement>, kind: "asr" | "eot") => {
|
||||
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
|
||||
try { const content = await file.text(); const rows = kind === "asr" ? parseTranscripts(content) : parseAnnotations(content); commit(kind === "asr" ? { transcripts: rows as Transcript[], annotations } : { transcripts, annotations: rows as Annotation[] });
|
||||
const initialCall = rows[0]?.call_id; if (initialCall) {
|
||||
const nextGroup = callDataset(initialCall, recordings, originalGroupsRef.current);
|
||||
if (datasetScope !== ALL_DATASETS && datasetScope !== nextGroup) setDatasetScope(nextGroup);
|
||||
setSearch(""); setFilter("all"); audioRef.current?.pause();
|
||||
if (initialCall === callId && audioRef.current) audioRef.current.currentTime = 0;
|
||||
if (initialCall !== callId) setAudioDuration(0);
|
||||
setCallId(initialCall); setCurrentMs(0); setTranscriptDraft(null); setSelectedId(kind === "eot" ? (rows[0] as Annotation).id : annotations.find(row => row.call_id === initialCall)?.id || null);
|
||||
} setShowFuture(false); notify(`已导入 ${rows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"},可撤销或保存到项目`);
|
||||
} catch (error) { notify(error instanceof Error ? error.message : "导入失败", "error"); }
|
||||
};
|
||||
const attachAudio = (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const url = URL.createObjectURL(file); objectUrls.current.add(url); setRecordings(rows => [...rows.filter(row => row.call_id !== callId), { call_id: callId, name: file.name, path: file.name, url }]); notify(`已关联录音:${file.name}。文件关联保留在当前页面`, "info"); };
|
||||
const exportCsv = (kind: "asr" | "eot", scope: "current" | "all") => {
|
||||
try {
|
||||
const rows = kind === "asr" ? transcripts : annotations;
|
||||
const exportRows = scope === "current" ? rows.filter(row => scopedCallIds.has(row.call_id)) : rows;
|
||||
const content = kind === "asr" ? exportTranscripts(exportRows as Transcript[]) : exportAnnotations(exportRows as Annotation[]);
|
||||
if (kind === "asr") parseTranscripts(content); else parseAnnotations(content);
|
||||
download(kind === "asr" ? "asr_history.csv" : "eot_labels.csv", content); setMenu(null);
|
||||
notify(`已导出${scope === "current" ? datasetScope : "全部数据"}的 ${exportRows.length} 条${kind === "asr" ? "对话转写" : "EOT 标注"}`);
|
||||
} catch (error) { notify(error instanceof Error ? error.message : "导出失败", "error"); }
|
||||
};
|
||||
const saveTranscript = () => { if (!transcriptDraft) return; try { const next = [...transcripts]; if (transcriptDraft.index === -1) next.push(transcriptDraft.row); else next[transcriptDraft.index] = transcriptDraft.row; parseTranscripts(exportTranscripts(next)); commit({ transcripts: next, annotations }); setTranscriptDraft(null); notify("对话片段已更新"); } catch (error) { notify(error instanceof Error ? error.message : "片段无效", "error"); } };
|
||||
|
||||
return <div className="app-shell">
|
||||
<input ref={asrImportRef} type="file" accept=".csv,text/csv" hidden onChange={event => void importCsv(event, "asr")} />
|
||||
<input ref={eotImportRef} type="file" accept=".csv,text/csv" hidden onChange={event => void importCsv(event, "eot")} />
|
||||
<input ref={audioImportRef} type="file" accept="audio/*,.mp3,.wav,.m4a,.ogg,.flac,.webm" hidden onChange={attachAudio} />
|
||||
<audio ref={audioRef} src={recording?.url} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} onLoadedMetadata={() => { const audio = audioRef.current; if (audio && Number.isFinite(audio.duration)) { setAudioDuration(Math.round(audio.duration * 1000)); audio.currentTime = Math.min(currentMs / 1000, audio.duration); audio.playbackRate = rate; } }} onTimeUpdate={() => { if (!playing && audioRef.current) setCurrentMs(Math.round(audioRef.current.currentTime * 1000)); }} onError={() => { if (recording) notify("录音无法播放,请重新关联文件", "error"); }} />
|
||||
|
||||
<header className="topbar"><a className="brand" href="/" aria-label="EOT Studio 首页"><span className="brand-mark"><Icon name="wave" size={22} /></span><span>EOT<span className="brand-light"> Studio</span><small>话轮标注工作台</small></span></a><div className="workspace-tag"><span className="status-dot" />本地工作空间</div><div className="top-actions">
|
||||
<span className="save-status"><span className={`status-dot ${dirty ? "orange" : ""}`} />{saving ? "正在保存…" : dirty ? "草稿自动保留" : savedTime ? "已保存到项目" : "未保存到项目"}</span>
|
||||
<div className="menu-wrap"><button className="button ghost" onClick={() => setMenu(menu === "import" ? null : "import")} aria-expanded={menu === "import"}><Icon name="upload" />导入<Icon name="chevron" size={14} /></button>{menu === "import" && <div className="dropdown"><button onClick={() => { asrImportRef.current?.click(); setMenu(null); }}><Icon name="file" />对话转写 CSV</button><button onClick={() => { eotImportRef.current?.click(); setMenu(null); }}><Icon name="file" />EOT 标注 CSV</button><button onClick={() => { audioImportRef.current?.click(); setMenu(null); }}><Icon name="headphones" />为当前通话关联录音</button></div>}</div>
|
||||
<div className="menu-wrap"><button className="button ghost" onClick={() => setMenu(menu === "export" ? null : "export")} aria-expanded={menu === "export"}><Icon name="download" />导出</button>{menu === "export" && <div className="dropdown">{datasetScope !== ALL_DATASETS && <><button onClick={() => exportCsv("eot", "current")}><Icon name="file" />当前数据集 EOT 标注 CSV</button><button onClick={() => exportCsv("asr", "current")}><Icon name="file" />当前数据集对话转写 CSV</button><div className="dropdown-divider" /></>}<button onClick={() => exportCsv("eot", "all")}><Icon name="file" />全部 EOT 标注 CSV</button><button onClick={() => exportCsv("asr", "all")}><Icon name="file" />全部对话转写 CSV</button></div>}</div>
|
||||
<button className="button primary" onClick={() => void saveWorkspace()} disabled={saving || loading}><Icon name="save" />{saving ? "保存中…" : "保存到项目"}</button>
|
||||
</div></header>
|
||||
|
||||
<div className="workspace-grid"><aside className="sidebar"><div className="sidebar-heading"><span className="eyebrow">对话库</span><span className="count-badge">{scopedCalls.length}</span></div><label className="dataset-scope"><span>数据集</span><select aria-label="选择数据集" value={datasetScope} disabled={loading} onChange={event => changeDatasetScope(event.target.value)}>{datasetGroups.map(group => <option key={group} value={group}>{group}</option>)}<option value={ALL_DATASETS}>全部数据</option></select><small>导出可选当前数据集,项目保存保留全部</small></label><label className="search-box"><Icon name="search" size={16} /><input aria-label="搜索通话" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索名称或通话 ID" /></label><div className="sidebar-tabs"><button className={filter === "all" ? "active" : ""} onClick={() => setFilter("all")}>全部对话</button><button className={filter === "pending" ? "active" : ""} onClick={() => setFilter("pending")}>待处理</button></div>
|
||||
<div className="call-list">{loading ? <p className="muted loading-label">正在读取工作空间…</p> : visibleCalls.length ? visibleCalls.map(row => <button key={row.id} className={`call-item ${callId === row.id ? "selected" : ""}`} onClick={() => chooseCall(row.id)} title={row.id}><span className="call-icon"><Icon name={row.recording ? "headphones" : "file"} size={17} /></span><span className="call-item-copy"><strong>{row.name}</strong><small>{row.id === "demo" ? "标准示例 · 可自由编辑" : row.id.slice(0, 18) + (row.id.length > 18 ? "…" : "")}</small><span>{row.count} 个判断点{row.pending > 0 && <em> · {row.pending} 待处理</em>}</span></span>{callId === row.id && <span className="selected-dot" />}</button>) : <p className="muted loading-label">没有找到对话</p>}</div>
|
||||
<div className="sidebar-bottom"><div><span>标注进度</span><strong>{completed}<span> / {scopedAnnotations.length}</span></strong></div><div className="progress-track"><span style={{ width: `${scopedAnnotations.length ? completed / scopedAnnotations.length * 100 : 0}%` }} /></div><p>每次判断,留出合适的接话空间。</p><button className="help-link" onClick={() => setHelpOpen(true)}><Icon name="help" size={16} />标注说明与快捷键</button></div>
|
||||
</aside>
|
||||
|
||||
<main className="main-workspace"><div className="page-heading"><div><div className="breadcrumb">工作空间 <span>/</span> {callName}</div><h1>对话标注<span className="offline-badge">离线转写</span></h1><p title={callId}>{callId === "demo" ? "从一段对话开始,判断用户是否已经说完。" : callId}</p></div><div className="view-switch" aria-label="工作模式"><button className={mode === "edit" ? "active" : ""} onClick={() => setMode("edit")}><Icon name="edit" size={15} />标注</button><button className={mode === "view" ? "active" : ""} onClick={() => { setMode("view"); setTranscriptDraft(null); }}>查看</button></div></div>
|
||||
<section className="audio-card panel"><div className="panel-heading"><div><h2><Icon name="headphones" />录音与时间轴</h2><p className="audio-filename" title={recording?.name}>{recording?.name || "示例尚未关联录音,可先查看转写与标注"}</p></div><button className="button small ghost" onClick={() => audioImportRef.current?.click()}><Icon name="upload" size={15} />{recording ? "更换录音" : "关联录音"}</button></div>
|
||||
<div className="player"><div className="player-main"><button className="icon-button" aria-label="后退 3 秒" onClick={() => seek(currentMs - 3000)}><Icon name="back" /></button><button className="play-button" aria-label={playing ? "暂停录音" : "播放录音"} onClick={() => void togglePlay()} disabled={!recording}><Icon name={playing ? "pause" : "play"} size={19} /></button><button className="icon-button" aria-label="前进 3 秒" onClick={() => seek(currentMs + 3000)}><Icon name="forward" /></button><span className="time-display">{formatTime(currentMs)}<span> / {formatTime(durationMs)}</span></span></div><div className="player-secondary"><label className="speed-label">播放速度<select aria-label="播放速度" value={rate} onChange={event => setRate(Number(event.target.value))}>{[.75, 1, 1.25, 1.5, 2].map(value => <option key={value} value={value}>{value}×</option>)}</select></label>{mode === "edit" && <button className="button small subtle" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} />在播放位置新增</button>}</div></div>
|
||||
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row }) => ({ ...row, transcript: showFuture || row.end_ms <= boundaryMs ? row.transcript : row.start_ms <= boundaryMs ? "跨越判断点的片段" : "后续片段" }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onDecisionChange={ms => {
|
||||
if (!selected || mode !== "edit") return;
|
||||
const time = Math.max(0, Math.min(durationMs, Math.round(ms)));
|
||||
if (callAnnotations.some(row => row.id !== selected.id && row.decision_ms === time)) { notify("这个时间点已有标注,请选择另一个位置", "info"); return; }
|
||||
updateAnnotation(selected.id, { decision_ms: time });
|
||||
}} onSelectAnnotation={selectAnnotation} readOnly={mode === "view"} />
|
||||
<div className="timeline-legend"><span><i className="legend-dot agent" />机器人</span><span><i className="legend-dot user" />用户</span><span><i className="legend-line" />播放位置</span><span><i className="legend-dot decision" />EOT 判断点</span><span className="timeline-tip">{mode === "edit" ? "点击定位 · 拖动橙色判断点调整时间" : "点击时间轴或判断点查看"}</span></div>
|
||||
</section>
|
||||
<CallReferencePanel references={callReferences.filter(row => row.call_id === callId)} />
|
||||
<section className="transcript-panel panel"><div className="panel-heading"><div><h2>对话转写<span className="count-badge">{callTranscripts.length}</span></h2><p>当前展示截至 <strong>{formatTime(boundaryMs)}</strong> 的对话历史</p></div><div className="transcript-actions"><label className="toggle-label"><input type="checkbox" checked={showFuture} onChange={event => setShowFuture(event.target.checked)} /><span className="toggle-track" />显示后续</label>{mode === "edit" && <button className="icon-button" aria-label="新增对话片段" onClick={() => { const start = Math.max(0, Math.min(currentMs, durationMs - 1)); setTranscriptDraft({ index: -1, row: { call_id: callId, role: "user", start_ms: start, end_ms: Math.min(durationMs, start + 1000), transcript: "" } }); }}><Icon name="plus" /></button>}</div></div>
|
||||
{showFuture && <div className="arbitration-note"><Icon name="help" size={15} />后续对话仅供辅助仲裁,不进入当前判断点的模型输入。</div>}
|
||||
<div className="transcript-list">{visibleTranscripts.length ? visibleTranscripts.map(({ row, index }) => <div key={index} className={`transcript-row ${row.role} ${row.end_ms > boundaryMs ? "future" : ""} ${currentMs >= row.start_ms && currentMs <= row.end_ms ? "playing-row" : ""}`}><div className={`speaker-avatar ${row.role}`}>{row.role === "agent" ? "机" : "人"}</div><div className="transcript-content"><div className="speaker-meta"><strong>{row.role === "agent" ? "机器人" : "用户"}</strong><button className="transcript-time" onClick={() => seek(row.start_ms)}>{formatTime(row.start_ms)} — {formatTime(row.end_ms)}</button>{row.end_ms > boundaryMs && <span className="future-tag">判断之后</span>}</div><p>{row.transcript || <span className="muted">(暂无转写)</span>}</p></div>{mode === "edit" && <div className="row-tools"><button className="icon-button" aria-label={`编辑 ${formatTime(row.start_ms)} 对话片段`} onClick={() => setTranscriptDraft({ index, row: { ...row } })}><Icon name="edit" size={15} /></button>{row.role === "user" && row.end_ms <= boundaryMs && selected && <button className="text-insert" title="将这段文本填入当前标注" onClick={() => updateAnnotation(selected.id, { user_text_at_decision: row.transcript })}>填入</button>}</div>}</div>) : <div className="empty-transcript"><Icon name="file" size={28} /><h3>这里还没有判断前的转写</h3><p>导入对话转写 CSV,或添加并校对语音片段。</p>{mode === "edit" && <button className="button small ghost" onClick={() => asrImportRef.current?.click()}>导入转写</button>}</div>}</div>
|
||||
{!showFuture && hiddenCount > 0 && <button className="hidden-history" onClick={() => setShowFuture(true)}><Icon name="clock" size={15} />{hiddenCount} 个后续或跨越判断点的片段已隐藏<span>展开辅助仲裁 →</span></button>}
|
||||
{selected && <div className="current-text-preview"><span>当前用户文本</span><p>{selected.user_text_at_decision || "尚未填写,请按判断时刻的录音核对。"}</p><small>{formatTime(selected.decision_ms)} 时已经说出的内容</small></div>}
|
||||
</section><div className="workspace-footer"><span><Icon name="check" size={14} />转写与标注使用同一录音时间轴</span><button onClick={undo} disabled={!undoStack.length || mode === "view"}><Icon name="undo" size={14} />撤销上一步</button></div>
|
||||
</main>
|
||||
|
||||
<aside className="annotation-sidebar"><div className="editor-heading"><div><span className="eyebrow">{mode === "view" ? "标注详情" : "当前标注"}</span><h2>这个位置,可以接话吗?</h2></div><span className="editor-number">{selected ? String(callAnnotations.findIndex(row => row.id === selected.id) + 1).padStart(2, "0") : "—"}</span></div>
|
||||
{selected ? <div className="editor-body"><div className="decision-card"><span><i className="legend-dot decision" />判断时间</span><strong>{formatTime(selected.decision_ms)}</strong><div className="review-actions"><button disabled={!recording} onClick={() => void review(false)}><Icon name="back" size={14} />听判断前</button><button disabled={!recording} onClick={() => void review(true)}><Icon name="forward" size={14} />听后续</button></div></div>
|
||||
<fieldset className="label-fieldset" disabled={mode === "view"}><legend>用户是否已完成当前表达?</legend>{([{ value: "1", title: "可以接话", detail: "当前表达已完成", icon: "check", number: "1", cls: "positive" }, { value: "0", title: "继续等待", detail: "现在接话会打断用户", icon: "clock", number: "2", cls: "negative" }, { value: "", title: "需要仲裁", detail: "证据不足,暂不确定", icon: "help", number: "3", cls: "pending" }] as const).map(choice => <button key={choice.number} className={`label-choice ${choice.cls} ${selected.can_reply_now === choice.value ? "active" : ""}`} aria-pressed={selected.can_reply_now === choice.value} onClick={() => setLabel(choice.value)}><span className="choice-symbol"><Icon name={choice.icon} size={18} /></span><span><strong>{choice.title}</strong><small>{choice.detail}</small></span><kbd>{choice.number}</kbd></button>)}</fieldset>
|
||||
<label className="form-field"><span>判断时的用户文本 <small>截至当前橙色判断点</small></span><textarea aria-label="判断时的用户文本" value={selected.user_text_at_decision} placeholder="例如:我明天下午…" readOnly={mode === "view"} onChange={event => updateAnnotation(selected.id, { user_text_at_decision: event.target.value })} rows={3} /><small>只填写当时已说出的内容,可保留未完成的表达。</small></label>
|
||||
<label className="form-field"><span>判断原因</span><select aria-label="判断原因" disabled={mode === "view"} value={selected.reason} onChange={event => updateAnnotation(selected.id, { reason: event.target.value })}><option value="">请选择原因</option>{reasonOptions.filter(row => row.can_reply_now === selected.can_reply_now).map(row => <option key={row.value} value={row.value}>{row.label}</option>)}</select></label>
|
||||
<div className="form-two"><label className="form-field"><span>标注把握</span><select aria-label="标注把握" disabled={mode === "view"} value={selected.confidence} onChange={event => updateAnnotation(selected.id, { confidence: event.target.value as Annotation["confidence"] })}><option value="">未填写</option><option value="high">高</option><option value="medium">中</option><option value="low">低</option></select></label><label className="form-field"><span>标注员</span><input aria-label="标注员" readOnly={mode === "view"} placeholder="姓名或 ID" value={selected.annotator} onChange={event => updateAnnotation(selected.id, { annotator: event.target.value })} /></label></div>
|
||||
{selectedError && <p className="field-error" role="alert">{selectedError}</p>}<div className="editor-bottom"><span><span className={`mini-dot ${labelClass(selected)}`} />{labelName(selected)}</span>{mode === "edit" && <button className="delete-button" onClick={() => { commit({ transcripts, annotations: annotations.filter(row => row.id !== selected.id) }); setSelectedId(callAnnotations.find(row => row.id !== selected.id)?.id || null); notify("已删除判断点,可撤销", "info"); }}><Icon name="trash" size={15} />删除</button>}</div>
|
||||
</div> : <div className="empty-editor"><span className="empty-marker"><Icon name="plus" size={26} /></span><h3>创建第一个判断点</h3><p>在时间轴上定位,再新增标注,记录此刻是否适合接话。</p>{mode === "edit" && <button className="button primary" onClick={addAnnotation} disabled={loading}><Icon name="plus" />新增判断点</button>}</div>}
|
||||
<div className="annotation-list-section"><div className="list-heading"><h3>本通话判断点<span>{callAnnotations.length}</span></h3>{mode === "edit" && <button className="icon-button" aria-label="新增判断点" onClick={addAnnotation} disabled={loading}><Icon name="plus" size={16} /></button>}</div><div className="annotation-list">{callAnnotations.map(row => <button key={row.id} className={`annotation-item ${row.id === selectedId ? "selected" : ""}`} onClick={() => selectAnnotation(row.id)}><span className={`mini-dot ${labelClass(row)}`} /><div><strong>{formatTime(row.decision_ms)}</strong><small>{row.user_text_at_decision || "尚未填写用户文本"}</small></div><span className={`label-pill ${labelClass(row)}`}>{labelName(row)}</span></button>)}{!callAnnotations.length && <p className="muted">暂无判断点</p>}</div></div>
|
||||
</aside></div>
|
||||
<footer className="keyboard-footer"><span>EOT Studio <i />让每一次接话更自然</span><div><span><kbd>空格</kbd> 播放 / 暂停</span><span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> 标签</span><span><kbd>[</kbd><kbd>]</kbd> 切换判断点</span><button onClick={() => setHelpOpen(true)} aria-label="查看快捷键"><Icon name="help" size={15} /></button></div></footer>
|
||||
{notice && <div className={`toast ${notice.kind}`} role={notice.kind === "error" ? "alert" : "status"}><Icon name={notice.kind === "success" ? "check" : "help"} /><span>{notice.message}</span><button aria-label="关闭消息" onClick={() => setNotice(null)}><Icon name="close" size={15} /></button></div>}
|
||||
{menu && <button className="menu-scrim" aria-label="关闭菜单" onClick={() => setMenu(null)} />}
|
||||
{transcriptDraft && <div className="modal-backdrop" onClick={() => setTranscriptDraft(null)}><section className="modal" role="dialog" aria-modal="true" aria-labelledby="transcript-dialog-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="transcript-dialog-title">{transcriptDraft.index === -1 ? "新增" : "编辑"}对话片段</h2><button className="icon-button" aria-label="关闭片段编辑" onClick={() => setTranscriptDraft(null)}><Icon name="close" /></button></div><label className="form-field"><span>说话人</span><select value={transcriptDraft.row.role} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, role: event.target.value as Transcript["role"] } })}><option value="user">用户</option><option value="agent">机器人</option></select></label><div className="form-two"><label className="form-field"><span>开始时间(毫秒)</span><input type="number" min="0" value={transcriptDraft.row.start_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, start_ms: Number(event.target.value) } })} /></label><label className="form-field"><span>结束时间(毫秒)</span><input type="number" min="1" value={transcriptDraft.row.end_ms} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, end_ms: Number(event.target.value) } })} /></label></div><label className="form-field"><span>转写内容</span><textarea rows={4} value={transcriptDraft.row.transcript} onChange={event => setTranscriptDraft({ ...transcriptDraft, row: { ...transcriptDraft.row, transcript: event.target.value } })} autoFocus /></label><div className="modal-actions">{transcriptDraft.index >= 0 && <button className="delete-button" onClick={() => { commit({ transcripts: transcripts.filter((_, index) => index !== transcriptDraft.index), annotations }); setTranscriptDraft(null); notify("已删除片段,可撤销", "info"); }}><Icon name="trash" />删除片段</button>}<button className="button ghost" onClick={() => setTranscriptDraft(null)}>取消</button><button className="button primary" onClick={saveTranscript}>保存片段</button></div></section></div>}
|
||||
{helpOpen && <div className="modal-backdrop" onClick={() => setHelpOpen(false)}><section className="modal help-modal" role="dialog" aria-modal="true" aria-labelledby="help-title" onClick={event => event.stopPropagation()}><div className="modal-heading"><h2 id="help-title">把判断留在正确的时刻</h2><button className="icon-button" aria-label="关闭说明" onClick={() => setHelpOpen(false)}><Icon name="close" /></button></div><p>橙色标记表示 EOT 判断时间,黑色细线表示播放位置。听判断之前的录音,再回答“机器人现在接话是否合适”。</p><ol><li><strong>可以接话:</strong>用户已完成当前表达,或已回答机器人问题。</li><li><strong>继续等待:</strong>用户只是停顿、犹豫或表达未完成。</li><li><strong>需要仲裁:</strong>录音或上下文不足,保留空标签。</li></ol><p>当前用户文本只写到橙色判断点。后续录音可辅助仲裁,展开的后续转写不会自动写入当前文本。</p><div className="shortcut-grid"><span><kbd>空格</kbd>播放 / 暂停</span><span><kbd>A</kbd>新增判断点</span><span><kbd>←</kbd><kbd>→</kbd>播放位置前后 1 秒</span><span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd>选择标签</span><span><kbd>[</kbd><kbd>]</kbd>上 / 下一个判断点</span><span><kbd>⌘ / Ctrl + S</kbd>保存到项目</span></div><p className="muted">编辑会保留为浏览器草稿。“保存到项目”写入工作数据目录;“导出”下载标准 CSV。上传录音的关联在页面关闭后需重新建立。</p><button className="button primary" onClick={() => setHelpOpen(false)}>开始标注</button></section></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.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); } }
|
||||
@@ -0,0 +1,33 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
.timeline {
|
||||
overflow: hidden;
|
||||
color: #333c34;
|
||||
background: #fffefb;
|
||||
border: 1px solid #e5e8dd;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 15px;
|
||||
}
|
||||
|
||||
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout, .decisionControls, .decisionControls > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbarTitle { gap: 14px; }
|
||||
.title { font-size: 14px; font-weight: 650; letter-spacing: .01em; }
|
||||
.duration { color: #6c776b; font-family: var(--font-geist-mono, monospace); font-size: 12px; }
|
||||
.duration > span { margin-left: 5px; font-family: inherit; font-size: 10px; color: #90978d; }
|
||||
.tools { gap: 13px; }
|
||||
.tools button { font: inherit; cursor: pointer; }
|
||||
.tools button:disabled { opacity: .35; cursor: default; }
|
||||
.locate { display: flex; align-items: center; gap: 5px; border: 0; background: none; color: #728064; font-size: 11px !important; }
|
||||
.locate > span { font-size: 20px; line-height: 1; }
|
||||
.zoom { height: 28px; gap: 3px; border: 1px solid #e7e9e0; border-radius: 7px; background: #fafbf5; }
|
||||
.zoom button { display: grid; place-items: center; width: 28px; height: 26px; border: 0; background: none; color: #606b57; font-size: 17px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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 { 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: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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 7px; }
|
||||
.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; }
|
||||
.toolbarTitle { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||
.title { font-size: 12px; }
|
||||
.duration { font-size: 10px; }
|
||||
.tools { gap: 6px; }
|
||||
.locate { padding: 0; font-size: 10px !important; }
|
||||
.trackLayout { grid-template-columns: 65px 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; }
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import styles from "./timeline.module.css";
|
||||
|
||||
export interface TimelineTranscript {
|
||||
role: "agent" | "user";
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
export interface TimelineAnnotation {
|
||||
id: string;
|
||||
decision_ms: number;
|
||||
can_reply_now: "1" | "0" | "";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TimelineProps {
|
||||
durationMs: number;
|
||||
currentMs: number;
|
||||
decisionMs: number | null;
|
||||
transcripts: TimelineTranscript[];
|
||||
annotations: TimelineAnnotation[];
|
||||
selectedId: string | null;
|
||||
waveform: number[] | null;
|
||||
readOnly?: boolean;
|
||||
onSeek: (ms: number) => void;
|
||||
onDecisionChange: (ms: number) => void;
|
||||
onSelectAnnotation: (id: string) => void;
|
||||
}
|
||||
|
||||
const ZOOM_LEVELS = [1, 2, 4, 8];
|
||||
const TICK_STEPS = [100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000];
|
||||
|
||||
function finite(value: number, fallback = 0) {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function timeLabel(ms: number, showMilliseconds = false) {
|
||||
const bounded = Math.max(0, Math.round(ms));
|
||||
const minutes = Math.floor(bounded / 60_000);
|
||||
const seconds = Math.floor((bounded % 60_000) / 1_000);
|
||||
const fraction = String(bounded % 1_000).padStart(3, "0");
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${showMilliseconds ? `.${fraction}` : ""}`;
|
||||
}
|
||||
|
||||
function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: string) {
|
||||
return value === "1" ? "可以接话" : value === "0" ? "继续等待" : reason ? "待仲裁" : "待标注";
|
||||
}
|
||||
|
||||
export default function Timeline({
|
||||
durationMs,
|
||||
currentMs,
|
||||
decisionMs,
|
||||
transcripts,
|
||||
annotations,
|
||||
selectedId,
|
||||
waveform,
|
||||
readOnly = false,
|
||||
onSeek,
|
||||
onDecisionChange,
|
||||
onSelectAnnotation,
|
||||
}: 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 zoomAnchorRef = useRef<number | null>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(800);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const duration = Math.max(
|
||||
1_000,
|
||||
finite(durationMs),
|
||||
...transcripts.map((item) => finite(item.end_ms)),
|
||||
...annotations.map((item) => finite(item.decision_ms)),
|
||||
finite(decisionMs ?? 0),
|
||||
);
|
||||
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 position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
|
||||
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const updateSize = () => setViewportWidth(viewport.clientWidth);
|
||||
updateSize();
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoomAnchorRef.current === null || !viewportRef.current) return;
|
||||
viewportRef.current.scrollLeft = zoomAnchorRef.current * canvasWidth - viewportWidth / 2;
|
||||
zoomAnchorRef.current = null;
|
||||
}, [canvasWidth, viewportWidth]);
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const desiredStep = (duration / canvasWidth) * 90;
|
||||
const step = TICK_STEPS.find((item) => item >= desiredStep) ?? Math.ceil(desiredStep / 600_000) * 600_000;
|
||||
const result = [];
|
||||
for (let time = 0; time <= duration; time += step) result.push({ time, label: timeLabel(time, step < 1_000) });
|
||||
return result;
|
||||
}, [duration, canvasWidth]);
|
||||
|
||||
const waveformPath = useMemo(() => {
|
||||
if (!waveform?.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;
|
||||
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));
|
||||
let peak = 0;
|
||||
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[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]);
|
||||
|
||||
function changeZoom(nextZoom: number) {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) zoomAnchorRef.current = (viewport.scrollLeft + viewport.clientWidth / 2) / canvasWidth;
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
|
||||
function locateDecision() {
|
||||
if (decision === null || !viewportRef.current) return;
|
||||
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function pointerTime(event: PointerEvent<HTMLElement>) {
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (event.clientX > bounds.right - 24) viewport.scrollLeft += 14;
|
||||
else if (event.clientX < bounds.left + 24) viewport.scrollLeft -= 14;
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent<HTMLElement>) {
|
||||
if (draggingRef.current?.pointerId !== event.pointerId) return;
|
||||
draggingRef.current = 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;
|
||||
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
|
||||
let next: number | null = null;
|
||||
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
|
||||
if (["ArrowRight", "ArrowUp", "+", "="].includes(event.key)) next = base + increment;
|
||||
if (event.key === "Home") next = 0;
|
||||
if (event.key === "End") next = duration;
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (kind === "seek") onSeek(clamp(next));
|
||||
else onDecisionChange(clamp(next));
|
||||
}
|
||||
|
||||
const trackStyle = { "--timeline-width": `${canvasWidth}px` } 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>
|
||||
</div>
|
||||
<div className={styles.tools}>
|
||||
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
|
||||
<span aria-hidden="true">⌖</span> 定位判断点
|
||||
</button>
|
||||
<div className={styles.zoom} aria-label="时间轴缩放">
|
||||
<button type="button" aria-label="缩小时间轴" disabled={zoom === 1} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) - 1])}>−</button>
|
||||
<span aria-live="polite">{zoom}×</span>
|
||||
<button type="button" aria-label="放大时间轴" disabled={zoom === 8} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) + 1])}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.trackLayout}>
|
||||
<div className={styles.trackNames} aria-hidden="true">
|
||||
<div className={styles.rulerName}>时间</div>
|
||||
<div className={styles.waveName}>录音</div>
|
||||
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
|
||||
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
|
||||
<div className={styles.markerName}>判断点</div>
|
||||
</div>
|
||||
<div className={styles.viewport} ref={viewportRef}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={trackStyle}
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
|
||||
onPointerDown={(event) => beginDrag(event, "seek")}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onLostPointerCapture={() => { draggingRef.current = null; }}
|
||||
onKeyDown={(event) => keyboardMove(event, "seek")}
|
||||
>
|
||||
<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>
|
||||
|
||||
{(["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) => {
|
||||
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}`}
|
||||
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>;
|
||||
})}
|
||||
</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>
|
||||
|
||||
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
[
|
||||
{
|
||||
"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": "是"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,295 @@
|
||||
export type ReplyLabel = "1" | "0" | "";
|
||||
export type Confidence = "high" | "medium" | "low" | "";
|
||||
|
||||
export interface Transcript {
|
||||
call_id: string;
|
||||
role: "agent" | "user";
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
/** Local editor identity. This field is never written to the CSV. */
|
||||
id: string;
|
||||
call_id: string;
|
||||
decision_ms: number;
|
||||
user_text_at_decision: string;
|
||||
can_reply_now: ReplyLabel;
|
||||
confidence: Confidence;
|
||||
reason: string;
|
||||
annotator: string;
|
||||
}
|
||||
|
||||
export interface ReasonOption {
|
||||
value: string;
|
||||
label: string;
|
||||
can_reply_now: ReplyLabel;
|
||||
}
|
||||
|
||||
export const reasonOptions: readonly ReasonOption[] = [
|
||||
{ value: "completed_turn", label: "当前话轮表达完成", can_reply_now: "1" },
|
||||
{ value: "completed_answer", label: "已完成当前问题的回答", can_reply_now: "1" },
|
||||
{ value: "mid_turn_pause", label: "同一话轮内停顿", can_reply_now: "0" },
|
||||
{ value: "hesitation", label: "犹豫或组织语言", can_reply_now: "0" },
|
||||
{ value: "unfinished_expression", label: "表达尚未完成", can_reply_now: "0" },
|
||||
{ value: "uncertain", label: "有争议或上下文不足", can_reply_now: "" },
|
||||
{ value: "audio_issue", label: "音频质量或录音缺失", can_reply_now: "" },
|
||||
{ value: "interrupted_unclear", label: "被插话后停止,意图不明确", can_reply_now: "" },
|
||||
];
|
||||
|
||||
const transcriptHeaders = ["call_id", "role", "start_ms", "end_ms", "transcript"];
|
||||
const annotationHeaders = [
|
||||
"call_id", "decision_ms", "user_text_at_decision", "can_reply_now",
|
||||
"confidence", "reason", "annotator",
|
||||
];
|
||||
|
||||
interface CSVRecord {
|
||||
cells: string[];
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Read RFC-style CSV without guessing types or interpreting an empty label as 0. */
|
||||
function readCSV(source: string): CSVRecord[] {
|
||||
const csv = source.replace(/^\uFEFF/, "");
|
||||
const records: CSVRecord[] = [];
|
||||
let cells: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
let afterQuote = false;
|
||||
let recordQuoted = false;
|
||||
let line = 1;
|
||||
let recordLine = 1;
|
||||
|
||||
const finishField = () => {
|
||||
cells.push(field);
|
||||
field = "";
|
||||
afterQuote = false;
|
||||
};
|
||||
const finishRecord = () => {
|
||||
finishField();
|
||||
// Empty physical lines are harmless, but a quoted empty record is data.
|
||||
if (cells.length !== 1 || cells[0] !== "" || recordQuoted) {
|
||||
records.push({ cells, line: recordLine });
|
||||
}
|
||||
cells = [];
|
||||
recordQuoted = false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < csv.length; i += 1) {
|
||||
const char = csv[i];
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (csv[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
afterQuote = true;
|
||||
}
|
||||
} else if (char === "\r" || char === "\n") {
|
||||
field += char;
|
||||
if (char === "\r" && csv[i + 1] === "\n") {
|
||||
field += "\n";
|
||||
i += 1;
|
||||
}
|
||||
line += 1;
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
finishField();
|
||||
} else if (char === "\r" || char === "\n") {
|
||||
finishRecord();
|
||||
if (char === "\r" && csv[i + 1] === "\n") i += 1;
|
||||
line += 1;
|
||||
recordLine = line;
|
||||
} else if (afterQuote) {
|
||||
throw new Error(`CSV 第 ${line} 行:结束引号后只能接逗号或换行。`);
|
||||
} else if (char === '"') {
|
||||
if (field !== "") {
|
||||
throw new Error(`CSV 第 ${line} 行:字段中的双引号需要用双引号包裹并转义。`);
|
||||
}
|
||||
inQuotes = true;
|
||||
recordQuoted = true;
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
if (inQuotes) throw new Error(`CSV 第 ${recordLine} 行:双引号字段没有闭合。`);
|
||||
if (field !== "" || cells.length > 0 || afterQuote || recordQuoted) finishRecord();
|
||||
return records;
|
||||
}
|
||||
|
||||
function readTable(source: string, expected: readonly string[]) {
|
||||
const records = readCSV(source);
|
||||
if (records.length === 0) throw new Error("CSV 为空,请包含表头。");
|
||||
const headers = records[0].cells.map((header) => header.trim());
|
||||
if (new Set(headers).size !== headers.length) throw new Error("CSV 表头包含重复字段。");
|
||||
const missing = expected.filter((header) => !headers.includes(header));
|
||||
if (missing.length) throw new Error(`CSV 表头缺少字段:${missing.join("、")}。`);
|
||||
const unexpected = headers.filter((header) => !expected.includes(header));
|
||||
if (unexpected.length) throw new Error(`CSV 表头包含未知字段:${unexpected.join("、")}。`);
|
||||
const indexes = new Map(headers.map((header, index) => [header, index]));
|
||||
const rows = records.slice(1).map((record) => {
|
||||
if (record.cells.length !== headers.length) {
|
||||
throw new Error(`CSV 第 ${record.line} 行:需要 ${headers.length} 列,实际有 ${record.cells.length} 列。`);
|
||||
}
|
||||
return {
|
||||
line: record.line,
|
||||
get: (name: string) => record.cells[indexes.get(name)!],
|
||||
};
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseMilliseconds(value: string, name: string, line: number): number {
|
||||
const trimmed = value.trim();
|
||||
const parsed = Number(trimmed);
|
||||
if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`CSV 第 ${line} 行:${name} 必须是非负整数毫秒。`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validMilliseconds(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function validateTranscript(row: Transcript): string | null {
|
||||
if (typeof row.call_id !== "string" || !row.call_id.trim()) return "call_id 不能为空。";
|
||||
if (row.role !== "agent" && row.role !== "user") return "role 只能是 agent 或 user。";
|
||||
if (!validMilliseconds(row.start_ms) || !validMilliseconds(row.end_ms)) {
|
||||
return "start_ms 和 end_ms 必须是非负整数毫秒。";
|
||||
}
|
||||
if (row.start_ms >= row.end_ms) return "start_ms 必须小于 end_ms。";
|
||||
if (typeof row.transcript !== "string") return "transcript 必须是文本。";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseTranscripts(csv: string): Transcript[] {
|
||||
return readTable(csv, transcriptHeaders).map(({ line, get }) => {
|
||||
const row: Transcript = {
|
||||
call_id: get("call_id").trim(),
|
||||
role: get("role").trim() as Transcript["role"],
|
||||
start_ms: parseMilliseconds(get("start_ms"), "start_ms", line),
|
||||
end_ms: parseMilliseconds(get("end_ms"), "end_ms", line),
|
||||
transcript: get("transcript"),
|
||||
};
|
||||
const error = validateTranscript(row);
|
||||
if (error) throw new Error(`CSV 第 ${line} 行:${error}`);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
let localIdSequence = 0;
|
||||
function localId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
localIdSequence += 1;
|
||||
return `eot-${Date.now().toString(36)}-${localIdSequence.toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function createAnnotation(callId: string, decisionMs: number): Annotation {
|
||||
const row: Annotation = {
|
||||
id: localId(),
|
||||
call_id: callId.trim(),
|
||||
decision_ms: decisionMs,
|
||||
user_text_at_decision: "",
|
||||
can_reply_now: "",
|
||||
confidence: "",
|
||||
reason: "",
|
||||
annotator: "",
|
||||
};
|
||||
const error = validateAnnotation(row);
|
||||
if (error) throw new Error(error);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Empty label is an undecided sample, while the string "0" is a valid negative label. */
|
||||
export function validateAnnotation(row: Annotation, others: readonly Annotation[] = []): string | null {
|
||||
if (typeof row.call_id !== "string" || !row.call_id.trim()) return "call_id 不能为空。";
|
||||
if (!validMilliseconds(row.decision_ms)) return "decision_ms 必须是非负整数毫秒。";
|
||||
if (!["1", "0", ""].includes(row.can_reply_now)) return "can_reply_now 只能是 1、0 或空。";
|
||||
if (!["high", "medium", "low", ""].includes(row.confidence)) return "confidence 只能是 high、medium、low 或空。";
|
||||
if (typeof row.user_text_at_decision !== "string") return "user_text_at_decision 必须是文本。";
|
||||
if (typeof row.annotator !== "string") return "annotator 必须是文本。";
|
||||
if (typeof row.reason !== "string") return "reason 必须是文本。";
|
||||
if (row.reason) {
|
||||
const option = reasonOptions.find((item) => item.value === row.reason);
|
||||
if (!option) return `未知的 reason:${row.reason}。`;
|
||||
if (option.can_reply_now !== row.can_reply_now) return "reason 与 can_reply_now 不一致,请选择对应的判断原因。";
|
||||
}
|
||||
if (others.some((other) => other.id !== row.id
|
||||
&& other.call_id.trim() === row.call_id.trim()
|
||||
&& other.decision_ms === row.decision_ms)) {
|
||||
return `通话 ${row.call_id} 在 ${row.decision_ms} ms 已有标注;同一判断位置只能保存一条。`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseAnnotations(csv: string): Annotation[] {
|
||||
const annotations: Annotation[] = [];
|
||||
for (const { line, get } of readTable(csv, annotationHeaders)) {
|
||||
const row: Annotation = {
|
||||
id: localId(),
|
||||
call_id: get("call_id").trim(),
|
||||
decision_ms: parseMilliseconds(get("decision_ms"), "decision_ms", line),
|
||||
user_text_at_decision: get("user_text_at_decision"),
|
||||
can_reply_now: get("can_reply_now").trim() as ReplyLabel,
|
||||
confidence: get("confidence").trim() as Confidence,
|
||||
reason: get("reason").trim(),
|
||||
annotator: get("annotator").trim(),
|
||||
};
|
||||
const error = validateAnnotation(row, annotations);
|
||||
if (error) throw new Error(`CSV 第 ${line} 行:${error}`);
|
||||
annotations.push(row);
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
function writeCSV(headers: readonly string[], rows: readonly (readonly (string | number)[])[]): string {
|
||||
const escape = (value: string | number) => {
|
||||
const text = String(value);
|
||||
return /[,"\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
};
|
||||
return [headers, ...rows].map((row) => row.map(escape).join(",")).join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
export function exportTranscripts(rows: readonly Transcript[]): string {
|
||||
return writeCSV(transcriptHeaders, rows.map((row, index) => {
|
||||
const error = validateTranscript(row);
|
||||
if (error) throw new Error(`转写第 ${index + 1} 条:${error}`);
|
||||
return [row.call_id, row.role, row.start_ms, row.end_ms, row.transcript];
|
||||
}));
|
||||
}
|
||||
|
||||
export function exportAnnotations(rows: readonly Annotation[]): string {
|
||||
// Validate against preceding rows so a duplicate cannot be exported even if UI ids match.
|
||||
const seen = new Set<string>();
|
||||
return writeCSV(annotationHeaders, rows.map((row, index) => {
|
||||
const error = validateAnnotation(row);
|
||||
if (error) throw new Error(`标注第 ${index + 1} 条:${error}`);
|
||||
const key = JSON.stringify([row.call_id.trim(), row.decision_ms]);
|
||||
if (seen.has(key)) throw new Error(`标注第 ${index + 1} 条:call_id + decision_ms 重复。`);
|
||||
seen.add(key);
|
||||
return [row.call_id, row.decision_ms, row.user_text_at_decision, row.can_reply_now, row.confidence, row.reason, row.annotator];
|
||||
}));
|
||||
}
|
||||
|
||||
export function getHistory(rows: readonly Transcript[], callId: string, decisionMs: number): Transcript[] {
|
||||
return rows.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => row.call_id === callId && row.end_ms <= decisionMs)
|
||||
.sort((a, b) => a.row.start_ms - b.row.start_ms || a.index - b.index)
|
||||
.map(({ row }) => row);
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
const integer = Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0;
|
||||
const minutes = Math.floor(integer / 60_000);
|
||||
const seconds = Math.floor(integer / 1_000) % 60;
|
||||
const milliseconds = integer % 1_000;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** 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,20 @@
|
||||
/** Use the actual Host header; Next may normalize request.url to localhost. */
|
||||
export function isSameOriginRequest(request: Request): boolean {
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
||||
const origin = request.headers.get("origin");
|
||||
// Non-browser clients do not necessarily send an Origin header.
|
||||
if (!origin) return true;
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const host = request.headers.get("host") ?? requestUrl.host;
|
||||
if (!host || /[\s/\\?#@]/.test(host)) return false;
|
||||
const expected = new URL(`${requestUrl.protocol}//${host}`);
|
||||
const actual = new URL(origin);
|
||||
if (actual.username || actual.password || actual.pathname !== "/" || actual.search || actual.hash) {
|
||||
return false;
|
||||
}
|
||||
return actual.origin !== "null" && actual.origin === expected.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
// 本地标注与录音接口始终读取当前工作文件。
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "eot-studio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @ts-nocheck -- Node's native TypeScript runner resolves explicit .ts imports.
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createAnnotation,
|
||||
exportAnnotations,
|
||||
exportTranscripts,
|
||||
formatTime,
|
||||
getHistory,
|
||||
parseAnnotations,
|
||||
parseTranscripts,
|
||||
reasonOptions,
|
||||
validateAnnotation,
|
||||
} from "../lib/annotations.ts";
|
||||
|
||||
const transcriptHeader = "call_id,role,start_ms,end_ms,transcript";
|
||||
const annotationHeader = "call_id,decision_ms,user_text_at_decision,can_reply_now,confidence,reason,annotator";
|
||||
const withoutId = (row) => Object.fromEntries(Object.entries(row).filter(([key]) => key !== "id"));
|
||||
|
||||
test("zero is a labeled negative example, and empty remains undecided", () => {
|
||||
const rows = parseAnnotations(`\uFEFF${annotationHeader}\r\ndemo,5200,我明天下午,0,high,mid_turn_pause,A\r\ndemo,9400,听不清,,,audio_issue,A\r\n`);
|
||||
assert.equal(rows[0].can_reply_now, "0");
|
||||
assert.equal(rows[1].can_reply_now, "");
|
||||
assert.equal(rows[1].confidence, "");
|
||||
assert.notEqual(rows[0].id, rows[1].id);
|
||||
assert.deepEqual(parseAnnotations(exportAnnotations(rows)).map(withoutId), rows.map(withoutId));
|
||||
assert.equal(exportAnnotations(rows).split("\r\n")[0], annotationHeader);
|
||||
assert.ok(!exportAnnotations(rows).includes(rows[0].id));
|
||||
});
|
||||
|
||||
test("commas, doubled quotes and multiline UTF-8 text survive CSV round trips", () => {
|
||||
const csv = `${transcriptHeader}\r\ndemo,user,100,800,"明天,\"\"下午\"\"\r\n都可以"\r\n`;
|
||||
const rows = parseTranscripts(csv);
|
||||
assert.equal(rows[0].transcript, '明天,"下午"\r\n都可以');
|
||||
assert.deepEqual(parseTranscripts(exportTranscripts(rows)), rows);
|
||||
const annotation = { ...createAnnotation("demo", 500), user_text_at_decision: '明天,"下午"\n先看看', annotator: 'A,"B"' };
|
||||
assert.deepEqual(parseAnnotations(exportAnnotations([annotation])).map(withoutId), [withoutId(annotation)]);
|
||||
});
|
||||
|
||||
test("required headers are validated while reordered columns remain usable", () => {
|
||||
assert.equal(parseTranscripts("transcript,end_ms,role,call_id,start_ms\n可以,40,user,demo,20")[0].transcript, "可以");
|
||||
assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms\ndemo,user,0,10"), /缺少字段.*transcript/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader},extra\ndemo,user,0,10,可以,x`), /未知字段.*extra/);
|
||||
assert.throws(() => parseTranscripts("call_id,role,start_ms,end_ms,transcript,role"), /重复字段/);
|
||||
assert.throws(() => parseAnnotations(""), /为空/);
|
||||
assert.deepEqual(parseAnnotations(annotationHeader), []);
|
||||
});
|
||||
|
||||
test("malformed CSV and incorrect column counts give useful line diagnostics", () => {
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"未闭合`), /第 2 行.*没有闭合/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,"可以"额外`), /结束引号后/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,可以"`), /双引号.*转义/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,0,100,多,一列`), /第 2 行.*实际有 6 列/);
|
||||
});
|
||||
|
||||
test("invalid times and roles are rejected instead of being silently coerced", () => {
|
||||
for (const start of ["", "-1", "1.5", "1e3", "9007199254740992"]) {
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,${start},2000,可以`), /非负整数毫秒/);
|
||||
}
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,assistant,0,100,可以`), /agent 或 user/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\ndemo,user,100,100,可以`), /小于 end_ms/);
|
||||
assert.throws(() => parseTranscripts(`${transcriptHeader}\n,user,0,100,可以`), /call_id 不能为空/);
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,-1,可以,1,high,completed_answer,A`), /非负整数毫秒/);
|
||||
});
|
||||
|
||||
test("labels, confidence, known reasons and reason-label consistency are checked", () => {
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,2,high,completed_answer,A`), /can_reply_now 只能/);
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,strong,completed_answer,A`), /confidence 只能/);
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,freeform,A`), /未知的 reason/);
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,0,high,completed_answer,A`), /不一致/);
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,uncertain,A`), /不一致/);
|
||||
for (const option of reasonOptions) {
|
||||
const row = { ...createAnnotation("demo", 100), can_reply_now: option.can_reply_now, reason: option.value };
|
||||
assert.equal(validateAnnotation(row), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("duplicate decision positions are rejected within a call and allowed across calls", () => {
|
||||
assert.throws(() => parseAnnotations(`${annotationHeader}\ndemo,100,可以,1,high,completed_answer,A\ndemo,100,等等,0,high,hesitation,B`), /已有标注/);
|
||||
const first = createAnnotation("demo", 100);
|
||||
const duplicate = createAnnotation("demo", 100);
|
||||
assert.match(validateAnnotation(duplicate, [first]), /已有标注/);
|
||||
assert.equal(validateAnnotation(first, [first]), null);
|
||||
assert.throws(() => exportAnnotations([first, first]), /重复/);
|
||||
assert.equal(parseAnnotations(`${annotationHeader}\ndemo,100,,,,,\nother,100,,,,,`).length, 2);
|
||||
});
|
||||
|
||||
test("history uses all earlier completed records, excludes crossing and future records, and preserves tied order", () => {
|
||||
const rows = parseTranscripts(`${transcriptHeader}\ndemo,user,300,440,我明天下午\ndemo,agent,100,240,什么时候方便\ndemo,user,400,700,不行后天可以\nother,user,0,5,其他通话\ndemo,user,300,400,同时出现的第二行\ndemo,agent,800,900,后天上午可以吗`);
|
||||
const before = rows.slice();
|
||||
assert.deepEqual(getHistory(rows, "demo", 520).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]);
|
||||
assert.deepEqual(getHistory(rows, "demo", 440).map((row) => row.transcript), ["什么时候方便", "我明天下午", "同时出现的第二行"]);
|
||||
assert.deepEqual(getHistory(rows, "demo", 100), []);
|
||||
assert.deepEqual(rows, before);
|
||||
});
|
||||
|
||||
test("new samples start undecided and time formatting preserves millisecond precision", () => {
|
||||
const row = createAnnotation("demo", 0);
|
||||
assert.equal(row.can_reply_now, "");
|
||||
assert.equal(row.reason, "");
|
||||
assert.equal(validateAnnotation(row), null);
|
||||
assert.equal(formatTime(0), "00:00.000");
|
||||
assert.equal(formatTime(5_200), "00:05.200");
|
||||
assert.equal(formatTime(62_345), "01:02.345");
|
||||
assert.equal(formatTime(3_600_000), "60:00.000");
|
||||
assert.throws(() => createAnnotation("demo", -1), /非负整数/);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
// @ts-expect-error Node's native TypeScript runner resolves explicit .ts imports.
|
||||
import { isSameOriginRequest } from "../lib/request-origin.ts";
|
||||
|
||||
function request(headers: Record<string, string>, url = "http://localhost:3000/api/workspace"): Request {
|
||||
return new Request(url, { method: "POST", headers });
|
||||
}
|
||||
|
||||
test("127.0.0.1 page may save when Next normalizes request.url to localhost", () => {
|
||||
assert.equal(isSameOriginRequest(request({
|
||||
Host: "127.0.0.1:3000",
|
||||
Origin: "http://127.0.0.1:3000",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
})), true);
|
||||
});
|
||||
|
||||
test("localhost and IPv6 same-origin hosts may save", () => {
|
||||
for (const host of ["localhost:3000", "[::1]:3000"]) {
|
||||
assert.equal(isSameOriginRequest(request({ Host: host, Origin: `http://${host}` })), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("evil origin and cross-site fetches remain forbidden", () => {
|
||||
assert.equal(isSameOriginRequest(request({ Host: "127.0.0.1:3000", Origin: "https://evil.example" })), false);
|
||||
assert.equal(isSameOriginRequest(request({
|
||||
Host: "127.0.0.1:3000", Origin: "http://127.0.0.1:3000", "Sec-Fetch-Site": "cross-site",
|
||||
})), false);
|
||||
});
|
||||
|
||||
test("forwarded-host cannot override the actual Host header", () => {
|
||||
assert.equal(isSameOriginRequest(request({
|
||||
Host: "localhost:3000", Origin: "https://evil.example", "X-Forwarded-Host": "evil.example",
|
||||
})), false);
|
||||
});
|
||||
|
||||
test("port/protocol mismatches and malformed origins are forbidden", () => {
|
||||
for (const origin of ["http://localhost:3001", "https://localhost:3000", "null", "broken", "http://localhost:3000/path"]) {
|
||||
assert.equal(isSameOriginRequest(request({ Host: "localhost:3000", Origin: origin })), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-browser clients without Origin remain supported, and URL is a Host fallback", () => {
|
||||
assert.equal(isSameOriginRequest(request({})), true);
|
||||
assert.equal(isSameOriginRequest(request({ Origin: "http://localhost:3000" })), true);
|
||||
assert.equal(isSameOriginRequest(request({ "Sec-Fetch-Site": "cross-site" })), false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user