diff --git a/labels/eot-studio/.gitignore b/labels/eot-studio/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/labels/eot-studio/.gitignore @@ -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 diff --git a/labels/eot-studio/AGENTS.md b/labels/eot-studio/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/labels/eot-studio/AGENTS.md @@ -0,0 +1,9 @@ + + +## 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. + + diff --git a/labels/eot-studio/README.md b/labels/eot-studio/README.md new file mode 100644 index 0000000..3c410b2 --- /dev/null +++ b/labels/eot-studio/README.md @@ -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 +``` + +打开终端显示的本地地址,通常为 。生产构建使用 `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` 默认取文件名去除扩展名后的部分;同一通话有多个项目录音时,默认使用发现的第一条,可点击“更换录音”重新关联本地文件。 diff --git a/labels/eot-studio/app/api/audio/route.ts b/labels/eot-studio/app/api/audio/route.ts new file mode 100644 index 0000000..3af1c35 --- /dev/null +++ b/labels/eot-studio/app/api/audio/route.ts @@ -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 = { + ".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 { + 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, { 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 { + return serve(request, false); +} + +export async function HEAD(request: Request): Promise { + return serve(request, true); +} diff --git a/labels/eot-studio/app/api/workspace/route.ts b/labels/eot-studio/app/api/workspace/route.ts new file mode 100644 index 0000000..3c354ba --- /dev/null +++ b/labels/eot-studio/app/api/workspace/route.ts @@ -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 }; + +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(operation: () => Promise): Promise { + 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 { + try { + await fs.access(file); + return true; + } catch (error) { + if (hasCode(error, "ENOENT")) return false; + throw error; + } +} + +async function withDiskLock(operation: () => Promise): Promise { + 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 { + const result: Recording[] = []; + async function visit(directory: string): Promise { + 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 { + 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; + 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 { + 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 { + 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, name: string, label: string): void { + if (typeof row[name] !== "string") throw new WorkspaceError(`${label} 的 ${name} 必须是文本。`); +} + +function requireMillis(row: Record, 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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new WorkspaceError(`${label} 必须是一个记录对象。`); + } + return value as Record; +} + +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 { + 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 { + 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); + } +} diff --git a/labels/eot-studio/app/globals.css b/labels/eot-studio/app/globals.css new file mode 100644 index 0000000..f0e4e17 --- /dev/null +++ b/labels/eot-studio/app/globals.css @@ -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; } +} diff --git a/labels/eot-studio/app/layout.tsx b/labels/eot-studio/app/layout.tsx new file mode 100644 index 0000000..7b58fed --- /dev/null +++ b/labels/eot-studio/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/labels/eot-studio/app/page.tsx b/labels/eot-studio/app/page.tsx new file mode 100644 index 0000000..a656348 --- /dev/null +++ b/labels/eot-studio/app/page.tsx @@ -0,0 +1,5 @@ +import Workbench from "./workbench"; + +export default function Home() { + return ; +} diff --git a/labels/eot-studio/app/workbench.tsx b/labels/eot-studio/app/workbench.tsx new file mode 100644 index 0000000..e223b82 --- /dev/null +++ b/labels/eot-studio/app/workbench.tsx @@ -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) { + 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 = { + 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 ; } +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([]); + const [annotations, setAnnotations] = useState([]); + const [recordings, setRecordings] = useState([]); + const [callReferences, setCallReferences] = useState([]); + const [callId, setCallId] = useState("demo"); + const [selectedId, setSelectedId] = useState(null); + const [currentMs, setCurrentMs] = useState(0); + const [audioDuration, setAudioDuration] = useState(0); + const [waveform, setWaveform] = useState(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(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(null); + const [undoStack, setUndoStack] = useState([]); + const [transcriptDraft, setTranscriptDraft] = useState<{ index: number; row: Transcript } | null>(null); + const [helpOpen, setHelpOpen] = useState(false); + const audioRef = useRef(null); + const asrImportRef = useRef(null); + const eotImportRef = useRef(null); + const audioImportRef = useRef(null); + const playBoundary = useRef(null); + const objectUrls = useRef(new Set()); + const versionRef = useRef(0); + const originalGroupsRef = useRef(new Map()); + + 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) => 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(".modal"); const focusables = () => [...(modal?.querySelectorAll('button:not(:disabled),input,select,textarea') || [])]; + (modal?.querySelector("[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(); + 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, 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) => { 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
+ void importCsv(event, "asr")} /> + void importCsv(event, "eot")} /> + +