Files
xiaobailing-turn-detection/labels/eot-studio/components/model-score-track.tsx
T

115 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>;
}