"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 = { maai: "#8866b2", smart_turn: "#278d95", silero_vad: "#c47b32" }; export const SCORE_HEIGHT = 172; export type ScoreThresholds = Record; export type ScoreVisibility = Record; 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, visibility, onVisibility }: { series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds; onThreshold: (model: ModelId, value: number) => void; visibility: ScoreVisibility; onVisibility: (model: ModelId, visible: boolean) => void; }) { return
{series.map(row => { const visible = visibility[row.model]; const point = visible ? scoreAt(row.points, currentMs, row.sample_interval_ms * 2) : null; return
{point ? point[1].toFixed(3) : "—"}
; })}
; } 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(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
setHoverMs(null)}> { 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 => )} {annotations.map(row => )} {paths.map(row => {row.checkpoints.map(([ms, score]) => event.stopPropagation()} onClick={event => { event.stopPropagation(); selectPoint(ms); }} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); selectPoint(ms); } }}>{row.name} · 原标注点评分 {score.toFixed(3)} · {time(ms)})} )} {hoverMs !== null && }
; }