为 EOT Studio 增加可展开的模型分数时间轴

This commit is contained in:
Xin Wang
2026-10-14 10:51:29 +08:00
parent 05f67739ce
commit 22556c67a4
12 changed files with 532 additions and 2 deletions
@@ -0,0 +1,59 @@
import { promises as fs } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { parseScoreExport, type ModelId, type ScoreResponse } from "@/lib/model-scores";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const root = path.resolve(process.cwd(), "..", "..");
const sources: { model: ModelId; directory: string; name: string }[] = [
{ model: "maai", directory: "maai-ch-kyoto", name: "MaAI" },
{ model: "smart_turn", directory: "smart-turn-v3.2", name: "Smart Turn" },
];
const json = (data: unknown, status = 200) => Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const callId = params.get("callId") || "";
const recordingPath = params.get("recordingPath") || "";
if (!callId || callId.length > 200 || !recordingPath || recordingPath.length > 1000) {
return json({ error: "请选择已关联项目录音的对话。" }, 400);
}
const payload: ScoreResponse = { callId, recordingPath, series: [], messages: [] };
try {
for (const source of sources) {
try {
// Fixed paths only: no request value is used to locate an export.
const file = path.join(root, "experiments", source.directory, "results", "score-timeline.json");
const data = parseScoreExport(JSON.parse(await fs.readFile(file, "utf8")), source.model);
const call = data.calls.find(item => item.call_id === callId && item.audio_path === recordingPath);
if (call) payload.series.push({ ...call, model: data.model, name: data.name,
score: data.score, sample_interval_ms: data.sample_interval_ms });
else payload.messages.push(`${source.name} 暂无这段录音的评分。`);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
console.error(`Cannot read ${source.name} scores`, error);
}
payload.messages.push(`${source.name} 评分暂时不可用。`);
}
}
if (payload.series.length) {
const dataRoot = await fs.realpath(path.join(root, "data"));
const audio = await fs.realpath(path.resolve(dataRoot, payload.series[0].audio_path));
const relative = path.relative(dataRoot, audio);
if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
return json({ error: "评分对应的录音不在项目目录内。" }, 422);
}
const hash = createHash("sha256").update(await fs.readFile(audio)).digest("hex");
payload.series = payload.series.filter(series => {
if (series.audio_sha256 === hash) return true;
payload.messages.push(`${series.name} 的评分对应旧录音,请重新生成评分。`);
return false;
});
}
return json(payload);
} catch (error) {
console.error("Cannot verify score recording", error);
return json({ error: "无法核对评分对应的录音,请检查项目文件。" }, 500);
}
}
+1 -1
View File
@@ -357,7 +357,7 @@ export default function Workbench() {
<main className="main-workspace"><div className="page-heading"><div><div className="breadcrumb">工作空间 <span>/</span> {callName}</div><h1>对话标注<span className="offline-badge">离线转写</span></h1><p title={callId}>{callId === "demo" ? "从一段对话开始,判断用户是否已经说完。" : callId}</p></div><div className="view-switch" aria-label="工作模式"><button className={mode === "edit" ? "active" : ""} onClick={() => setMode("edit")}><Icon name="edit" size={15} />标注</button><button className={mode === "view" ? "active" : ""} onClick={() => { setMode("view"); setTranscriptDraft(null); }}>查看</button></div></div>
<section className="audio-card panel"><div className="panel-heading"><div><h2><Icon name="headphones" />录音与时间轴</h2><p className="audio-filename" title={recording?.name}>{recording?.name || "示例尚未关联录音,可先查看转写与标注"}</p></div><button className="button small ghost" onClick={() => audioImportRef.current?.click()}><Icon name="upload" size={15} />{recording ? "更换录音" : "关联录音"}</button></div>
<div className="player"><div className="player-main"><button className="icon-button" aria-label="后退 3 秒" onClick={() => seek(currentMs - 3000)}><Icon name="back" /></button><button className="play-button" aria-label={playing ? "暂停录音" : "播放录音"} onClick={() => void togglePlay()} disabled={!recording}><Icon name={playing ? "pause" : "play"} size={19} /></button><button className="icon-button" aria-label="前进 3 秒" onClick={() => seek(currentMs + 3000)}><Icon name="forward" /></button><span className="time-display">{formatTime(currentMs)}<span> / {formatTime(durationMs)}</span></span></div><div className="player-secondary"><label className="speed-label">播放速度<select aria-label="播放速度" value={rate} onChange={event => setRate(Number(event.target.value))}>{[.75, 1, 1.25, 1.5, 2].map(value => <option key={value} value={value}>{value}×</option>)}</select></label>{mode === "edit" && <div className="menu-wrap"><button className="button small subtle" onClick={() => setMenu(menu === "create" ? null : "create")} disabled={loading} aria-expanded={menu === "create"} aria-haspopup="menu"><Icon name="plus" size={16} />在播放位置新增<Icon name="chevron" size={13} /></button>{menu === "create" && <div className="dropdown" role="menu" aria-label="新增项目"><button role="menuitem" onClick={addAnnotation}>EOT 判断点</button><button role="menuitem" onClick={() => addTranscriptAt("agent")}>机器人转写</button><button role="menuitem" onClick={() => addTranscriptAt("user")}>用户转写</button></div>}</div>}</div></div>
<Timeline durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row, index }) => ({ ...row, index }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onMoveAnnotation={moveAnnotation} onSelectAnnotation={selectAnnotation} onSelectTranscript={index => {
<Timeline callId={callId} recordingPath={recording?.url.startsWith("/api/audio?") ? recording.path : null} durationMs={durationMs} currentMs={currentMs} decisionMs={selected?.decision_ms ?? null} transcripts={callTranscripts.map(({ row, index }) => ({ ...row, index }))} annotations={callAnnotations} selectedId={selectedId} waveform={waveform} onSeek={seek} onMoveAnnotation={moveAnnotation} onSelectAnnotation={selectAnnotation} onSelectTranscript={index => {
const row = transcripts[index]; seek(row.start_ms);
audioRef.current?.pause(); setTranscriptDraft({ index, row: { ...row } });
}} onResizeTranscript={resizeTranscript} editingTranscriptIndex={transcriptDraft?.index ?? null} onAddAnnotation={addAnnotationAt} readOnly={mode === "view"} />