为 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,114 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { scoreAt, scorePath, type ModelId, type ScoreResponse, type ScoreSeries } from "@/lib/model-scores";
import type { TimelineAnnotation } from "./timeline";
import styles from "./timeline.module.css";
const EMPTY: ScoreSeries[] = [];
const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95" };
export const SCORE_HEIGHT = 172;
export type ScoreThresholds = Record<ModelId, number>;
const time = (ms: number) => `${Math.floor(ms / 60000).toString().padStart(2, "0")}:${(ms % 60000 / 1000).toFixed(3).padStart(6, "0")}`;
export function useModelScores(callId: string, recordingPath: string | null, expanded: boolean) {
const key = `${callId}:${recordingPath}`;
const [retry, setRetry] = useState(0);
const [state, setState] = useState<{ key: string; status: "loading" | "ready" | "error"; data?: ScoreResponse; error?: string } | null>(null);
useEffect(() => {
if (!expanded || !recordingPath) return;
const controller = new AbortController();
setState({ key, status: "loading" });
void (async () => {
try {
const query = new URLSearchParams({ callId, recordingPath });
const response = await fetch(`/api/model-scores?${query}`, { cache: "no-store", signal: controller.signal });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "评分读取失败。");
if (data.callId !== callId || data.recordingPath !== recordingPath || !Array.isArray(data.series)) throw new Error("评分与当前录音不匹配。");
if (!controller.signal.aborted) setState({ key, status: "ready", data });
} catch (error) {
if (!controller.signal.aborted) setState({ key, status: "error", error: error instanceof Error ? error.message : "评分读取失败。" });
}
})();
return () => controller.abort();
}, [callId, recordingPath, expanded, key, retry]);
const active = state?.key === key ? state : null;
return {
series: recordingPath && active?.status === "ready" ? active.data?.series || EMPTY : EMPTY,
status: !recordingPath ? "empty" : active?.status || "loading",
messages: !recordingPath ? ["请使用项目原录音查看模型评分;新关联的录音需先生成对应评分。"]
: active?.status === "error" ? [active.error || "评分读取失败。"] : active?.data?.messages || [],
retry: () => setRetry(value => value + 1),
};
}
export function ScoreControls({ series, currentMs, thresholds, onThreshold }: {
series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds;
onThreshold: (model: ModelId, value: number) => void;
}) {
return <div className={styles.scoreControls}>
{series.map(row => {
const point = scoreAt(row.points, currentMs, row.sample_interval_ms * 2);
return <div className={styles.modelControl} key={row.model} style={{ color: COLORS[row.model] }}>
<span className={styles.modelSwatch} /><strong>{row.name}</strong>
<output aria-live="off" title={point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output>
<label>参考线<select aria-label={`${row.name} 参考阈值`} value={thresholds[row.model]}
onChange={event => onThreshold(row.model, Number(event.target.value))}>
{(row.model === "maai" ? [.11, .5, .8] : [.5, .8]).map(value => <option key={value} value={value}>{value.toFixed(2)}</option>)}
</select></label>
</div>;
})}
</div>;
}
export default function ModelScoreTrack({ series, width, durationMs, currentMs, thresholds, annotations, onSeek, onSelectAnnotation }: {
series: ScoreSeries[]; width: number; durationMs: number; currentMs: number;
thresholds: ScoreThresholds; annotations: TimelineAnnotation[];
onSeek: (ms: number) => void; onSelectAnnotation: (id: string, seekToPoint?: boolean) => void;
}) {
const [hoverMs, setHoverMs] = useState<number | null>(null);
const paths = useMemo(() => series.map(row => ({ ...row,
path: scorePath(row.points, durationMs, width, SCORE_HEIGHT, row.sample_interval_ms) })), [series, durationMs, width]);
const y = (p: number) => 12 + (1 - p) * (SCORE_HEIGHT - 24);
const x = (ms: number) => ms / durationMs * width;
const inspect = hoverMs ?? currentMs;
const selectPoint = (ms: number) => {
const annotation = annotations.find(row => row.decision_ms === ms);
if (annotation) onSelectAnnotation(annotation.id, false);
onSeek(ms);
};
return <div className={styles.scoreTrack} onPointerLeave={() => setHoverMs(null)}>
<svg width={width} height={SCORE_HEIGHT} viewBox={`0 0 ${width} ${SCORE_HEIGHT}`}
role="group" aria-label="MaAI 与 Smart Turn 的 EOT 分数,纵轴 0 到 1,横轴与录音时间一致"
onPointerMove={event => {
const bounds = event.currentTarget.getBoundingClientRect();
setHoverMs(Math.max(0, Math.min(durationMs, (event.clientX - bounds.left) / bounds.width * durationMs)));
}}>
{[0, .25, .5, .75, 1].map(p => <line key={p} x1={0} x2={width} y1={y(p)} y2={y(p)} stroke="#e8e9e4" strokeDasharray={p === 0 || p === 1 ? undefined : "2 4"} />)}
{annotations.map(row => <line key={row.id} x1={x(row.decision_ms)} x2={x(row.decision_ms)} y1={0} y2={SCORE_HEIGHT}
stroke={row.can_reply_now === "0" ? "#c68c6b" : "#96a781"} strokeDasharray="2 5" opacity=".4" />)}
{paths.map(row => <g key={row.model}>
<line x1={0} x2={width} y1={y(thresholds[row.model])} y2={y(thresholds[row.model])}
stroke={COLORS[row.model]} strokeDasharray={row.model === "maai" ? "6 5" : "2 4"} opacity=".55" />
<path d={row.path} fill="none" stroke={COLORS[row.model]} strokeWidth={1.6} strokeLinejoin="round" pointerEvents="none" />
{row.checkpoints.map(([ms, score]) => <circle key={ms} cx={x(ms)} cy={y(score)} r={3.8}
fill="#fffefb" stroke={COLORS[row.model]} strokeWidth={1.8} tabIndex={0} role="button"
aria-label={`${row.name} 原标注点评分 ${time(ms)},${score.toFixed(3)},点击定位`}
onPointerDown={event => event.stopPropagation()}
onClick={event => { event.stopPropagation(); selectPoint(ms); }}
onKeyDown={event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); selectPoint(ms); }
}}><title>{row.name} · 原标注点评分 {score.toFixed(3)} · {time(ms)}</title></circle>)}
</g>)}
{hoverMs !== null && <line x1={x(hoverMs)} x2={x(hoverMs)} y1={0} y2={SCORE_HEIGHT} stroke="#77816c" opacity=".5" pointerEvents="none" />}
</svg>
<div className={styles.scoreInspection} aria-hidden="true">
<span>{time(inspect)}</span>
{series.map(row => {
const point = scoreAt(row.points, inspect, row.sample_interval_ms * 2);
return <span key={row.model} style={{ color: COLORS[row.model] }}>{row.name} <b>{point ? point[1].toFixed(3) : "—"}</b></span>;
})}
</div>
</div>;
}
@@ -117,3 +117,37 @@
.leftDot, .rightDot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.leftDot { background: #6c917c; }
.rightDot { background: #658cab; }
.scorePanelHeader { padding: 0 20px 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.scoreToggle { display: inline-flex; align-items: center; gap: 8px; padding: 6px 0; background: none; border: 0; color: #67775b; cursor: pointer; font: inherit; font-size: 11px; }
.scoreToggle strong { font-size: 12px; font-weight: 600; color: #45553e; }
.scoreToggle > span:last-child { color: #929b89; font-size: 10px; }
.chevron, .chevronOpen { display: inline-block; width: 12px; font-size: 23px; line-height: 16px; text-align: center; }
.chevronOpen { transform: rotate(90deg); }
.scoreControls { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
.modelControl { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid #e9e9e2; border-radius: 7px; background: #fafbf7; font-size: 10px; }
.modelControl strong { font-weight: 600; }
.modelControl output { width: 37px; font-family: monospace; font-variant-numeric: tabular-nums; text-align: center; }
.modelControl label { display: flex; align-items: center; gap: 4px; color: #88927e; padding-left: 4px; border-left: 1px solid #e2e5db; }
.modelControl select { padding: 1px 0; border: 0; background: transparent; font-size: 10px; color: #637258; cursor: pointer; }
.modelSwatch { width: 14px; height: 2px; background: currentColor; }
.scoreDescription { padding: 0 22px 10px; font-size: 10px; line-height: 1.7; color: #88927f; }
.scoreDescription button { border: 0; background: none; color: #536f40; cursor: pointer; text-decoration: underline; font-size: inherit; }
.trackNames > .scoreAxis { padding: 12px 8px; flex-direction: column; justify-content: space-between; font-size: 10px; color: #929a87; border-top: 1px solid #e7e9e0; }
.scoreAxis span:first-child, .scoreAxis span:last-child { font-family: monospace; align-self: flex-end; }
.scoreAxis span:nth-child(2) { color: #647759; }
.scoreTrack { position: relative; height: 172px; background: #fdfefb; border-top: 1px solid #e7e9e0; }
.scoreTrack > svg { display: block; overflow: hidden; }
.scoreTrack circle { cursor: pointer; }
.scoreTrack circle:hover, .scoreTrack circle:focus-visible { stroke-width: 3; outline: none; r: 5; }
.scoreInspection { position: sticky; left: 10px; margin-top: -26px; width: max-content; max-width: 100%; display: flex; gap: 12px; padding: 4px 7px; border-radius: 4px; color: #8c9682; background: #fffefbe8; font: 10px monospace; pointer-events: none; }
.scoreInspection b { font-weight: 500; }
.scoreEmpty { position: sticky; left: 0; display: grid; place-items: center; color: #929b88; font-size: 11px; background: #fdfefb; border-top: 1px solid #e7e9e0; }
.scoreFootnote { margin: 0; padding: 9px 22px 0; color: #8c9583; font-size: 10px; line-height: 1.7; }
.scoreToggle:focus-visible, .modelControl select:focus-visible { outline: 2px solid #758962; outline-offset: 3px; border-radius: 3px; }
@media (max-width: 640px) {
.scorePanelHeader { padding-inline: 12px; gap: 5px; }
.scoreDescription, .scoreFootnote { padding-inline: 12px; }
.scoreControls { gap: 5px; }
.modelControl { padding: 4px 5px; }
}
+33 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react";
import styles from "./timeline.module.css";
import { resizeTranscriptRange, snapTimelineTime, type SnapPoint } from "@/lib/timeline-edit";
import ModelScoreTrack, { ScoreControls, SCORE_HEIGHT, useModelScores, type ScoreThresholds } from "./model-score-track";
export interface TimelineTranscript {
index: number;
@@ -29,6 +30,8 @@ export interface TimelineAnnotation {
}
export interface TimelineProps {
callId: string;
recordingPath: string | null;
durationMs: number;
currentMs: number;
decisionMs: number | null;
@@ -66,6 +69,8 @@ function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: st
}
export default function Timeline({
callId,
recordingPath,
durationMs,
currentMs,
decisionMs,
@@ -83,6 +88,10 @@ export default function Timeline({
onAddAnnotation,
}: TimelineProps) {
const helpId = useId();
const scoreRegionId = useId();
const [scoresOpen, setScoresOpen] = useState(false);
const [thresholds, setThresholds] = useState<ScoreThresholds>({ maai: .5, smart_turn: .5 });
const scores = useModelScores(callId, recordingPath, scoresOpen);
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<
@@ -300,7 +309,7 @@ export default function Timeline({
const channelCount = waveform?.length || 1;
const channelLabel = (index: number) => channelCount === 1 ? "单声道" : channelCount === 2 ? index === 0 ? "左声道 L" : "右声道 R" : `声道 ${index + 1}`;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount } as CSSProperties;
const trackStyle = { "--timeline-width": `${canvasWidth}px`, height: 204 + 76 * channelCount + (scoresOpen ? SCORE_HEIGHT : 0) } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
@@ -321,6 +330,20 @@ export default function Timeline({
</div>
</div>
<div className={styles.scorePanelHeader}>
<button type="button" className={styles.scoreToggle} aria-expanded={scoresOpen} aria-controls={scoreRegionId}
onClick={() => setScoresOpen(value => !value)}>
<span aria-hidden="true" className={scoresOpen ? styles.chevronOpen : styles.chevron}>›</span>
<strong>模型 EOT 分数</strong><span>{scoresOpen ? "收起" : "展开 MaAI / Smart Turn 对比"}</span>
</button>
{scoresOpen && <ScoreControls series={scores.series} currentMs={current}
thresholds={thresholds} onThreshold={(model, value) => setThresholds(previous => ({ ...previous, [model]: value }))} />}
</div>
{scoresOpen && <div className={styles.scoreDescription} role="status">
{scores.status === "loading" ? "正在读取模型分数…" : scores.status === "error" ? <>{scores.messages.join(" ")} <button type="button" onClick={scores.retry}>重新读取</button></>
: <>实线:连续回放评分 · 空心点:原标注点评分 · 虚线:参考阈值。分数越高越倾向接话,MaAI 使用 1 − 用户近期发声分数。{scores.messages.join(" ")}</>}
</div>}
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
@@ -328,6 +351,7 @@ export default function Timeline({
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
{scoresOpen && <div className={styles.scoreAxis} style={{ height: SCORE_HEIGHT }}><span>1.0</span><span>接话分数</span><span>0.0</span></div>}
</div>
<div className={styles.viewport} ref={viewportRef}>
<div
@@ -424,6 +448,12 @@ export default function Timeline({
})}
</div>
{scoresOpen && <div id={scoreRegionId}>
{scores.series.length ? <ModelScoreTrack series={scores.series} width={canvasWidth} durationMs={duration}
currentMs={current} thresholds={thresholds} annotations={annotations} onSeek={onSeek} onSelectAnnotation={onSelectAnnotation} />
: <div className={styles.scoreEmpty} style={{ height: SCORE_HEIGHT, width: viewportWidth }}>{scores.status === "loading" ? "正在载入评分曲线…" : "当前录音暂无可显示的模型分数"}</div>}
</div>}
{activeSnap && <div className={styles.snapGuide} style={{ left: position(activeSnap.ms) }} aria-hidden="true" />}
<div className={styles.playhead} style={{ left: position(current) }}>
<button type="button" className={styles.playheadHandle} aria-label={`拖动播放头 ${timeLabel(current, true)}`}
@@ -440,6 +470,8 @@ export default function Timeline({
</div>
</div>
{scoresOpen && scores.series.length > 0 && <p className={styles.scoreFootnote}>点击曲线定位录音;悬停查看分数。MaAI 约每 100 ms、Smart Turn 每 200 ms 更新。原标注点评分使用独立历史窗口,与连续曲线可能不同;编辑标注或转写不会重算已有分数。</p>}
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{resizePreview ? "拖动边界调整片段 · 松开保存 · Alt 暂停吸附" : activeSnap ? `已吸附:${activeSnap.label} ${timeLabel(activeSnap.ms, true)}` : "拖动判断点或播放头 · 自动吸附 · Alt 暂停吸附"}</span>