2026-10-14 10:51:29 +08:00
|
|
|
|
"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[] = [];
|
2026-10-22 21:25:47 +08:00
|
|
|
|
const COLORS: Record<ModelId, string> = { maai: "#8866b2", smart_turn: "#278d95", silero_vad: "#c47b32" };
|
2026-10-14 10:51:29 +08:00
|
|
|
|
export const SCORE_HEIGHT = 172;
|
|
|
|
|
|
export type ScoreThresholds = Record<ModelId, number>;
|
2026-10-14 11:01:39 +08:00
|
|
|
|
export type ScoreVisibility = Record<ModelId, boolean>;
|
2026-10-14 10:51:29 +08:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 11:01:39 +08:00
|
|
|
|
export function ScoreControls({ series, currentMs, thresholds, onThreshold, visibility, onVisibility }: {
|
2026-10-14 10:51:29 +08:00
|
|
|
|
series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds;
|
|
|
|
|
|
onThreshold: (model: ModelId, value: number) => void;
|
2026-10-14 11:01:39 +08:00
|
|
|
|
visibility: ScoreVisibility; onVisibility: (model: ModelId, visible: boolean) => void;
|
2026-10-14 10:51:29 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
return <div className={styles.scoreControls}>
|
|
|
|
|
|
{series.map(row => {
|
2026-10-14 11:01:39 +08:00
|
|
|
|
const visible = visibility[row.model];
|
|
|
|
|
|
const point = visible ? scoreAt(row.points, currentMs, row.sample_interval_ms * 2) : null;
|
2026-10-14 10:51:29 +08:00
|
|
|
|
return <div className={styles.modelControl} key={row.model} style={{ color: COLORS[row.model] }}>
|
2026-10-14 11:01:39 +08:00
|
|
|
|
<button type="button" role="switch" aria-checked={visible} aria-label={`显示 ${row.name} 分数`}
|
|
|
|
|
|
className={styles.modelVisibility} onClick={() => onVisibility(row.model, !visible)}>
|
|
|
|
|
|
<span className={styles.modelSwitch} aria-hidden="true" /><strong>{row.name}</strong>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<output aria-live="off" title={!visible ? "已隐藏" : point ? `评分时刻 ${time(point[0])}` : "当前位置暂无评分"}>{point ? point[1].toFixed(3) : "—"}</output>
|
|
|
|
|
|
<label>参考线<select aria-label={`${row.name} 参考阈值`} value={thresholds[row.model]} disabled={!visible}
|
2026-10-14 10:51:29 +08:00
|
|
|
|
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}`}
|
2026-10-22 21:25:47 +08:00
|
|
|
|
role="group" aria-label="VAP、Smart Turn 与 Silero VAD 分数对比,纵轴 0 到 1,横轴与录音时间一致"
|
2026-10-14 10:51:29 +08:00
|
|
|
|
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>;
|
|
|
|
|
|
}
|