"use client"; import { useEffect, useMemo, useRef, useState, useId } from "react"; import { scoreAt, scorePath, type ModelId, type ScoreResponse, type ScoreSeries } from "@/lib/model-scores"; import { createPortal } from "react-dom"; import { SCORE_MODELS, SCORE_MODEL_IDS, readScorePreferences } from "@/lib/score-models"; import type { TimelineAnnotation } from "./timeline"; import styles from "./timeline.module.css"; const EMPTY: ScoreSeries[] = []; const COLORS = Object.fromEntries(SCORE_MODEL_IDS.map(id => [id, SCORE_MODELS[id].color])) as Record; 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 useScorePreferences() { const [preferences, setPreferences] = useState(() => readScorePreferences(null)); const [ready, setReady] = useState(false); useEffect(() => { try { setPreferences(readScorePreferences(JSON.parse(localStorage.getItem("eot-score-preferences-v1") || "null"))); } catch { /* Defaults survive unavailable or invalid browser storage. */ } setReady(true); }, []); useEffect(() => { if (!ready) return; try { localStorage.setItem("eot-score-preferences-v1", JSON.stringify({ version: 1, ...preferences })); } catch { /* Preferences are optional. */ } }, [ready, preferences]); return { ...preferences, setVisibility: (visibility: ScoreVisibility) => setPreferences(previous => ({ ...previous, visibility })), setThreshold: (id: ModelId, value: number) => setPreferences(previous => ({ ...previous, thresholds: { ...previous.thresholds, [id]: value } })), }; } export function ScoreControls({ series, currentMs, thresholds, onThreshold, visibility, onVisibility, onFocus, focusedModel, loading }: { series: ScoreSeries[]; currentMs: number; thresholds: ScoreThresholds; onThreshold: (model: ModelId, value: number) => void; visibility: ScoreVisibility; onVisibility: (visibility: ScoreVisibility) => void; onFocus: (model: ModelId | null) => void; focusedModel: ModelId | null; loading: boolean; }) { const [panel, setPanel] = useState(null); const [query, setQuery] = useState(""); const [position, setPosition] = useState({ top: 0, left: 0 }); const [previousSelection, setPreviousSelection] = useState(null); const anchor = useRef(null); const popup = useRef(null); const search = useRef(null); const panelId = useId(); const available = new Set(series.map(row => row.model)); const selected = series.filter(row => visibility[row.model]); function close() { setPanel(null); anchor.current?.focus({ preventScroll: true }); } function open(kind: ModelId | "choose", button: HTMLButtonElement) { anchor.current = button; if (panel === kind) { close(); return; } setQuery(""); setPanel(kind); } useEffect(() => { if (!panel) return; const place = () => { const r = anchor.current?.getBoundingClientRect(); if (!r) return; const width = Math.min(360, window.innerWidth - 24); const height = Math.min(popup.current?.getBoundingClientRect().height || 460, window.innerHeight - 24); setPosition({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: Math.max(12, Math.min(r.bottom + 8, window.innerHeight - height - 12)) }); }; place(); (panel === "choose" ? search.current : popup.current?.querySelector("button,select"))?.focus({ preventScroll: true }); const outside = (event: PointerEvent) => { if (!popup.current?.contains(event.target as Node) && !anchor.current?.contains(event.target as Node)) setPanel(null); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); } // Keep workbench hotkeys from changing annotations while browsing settings. else if (popup.current?.contains(event.target as Node)) event.stopPropagation(); }; document.addEventListener("pointerdown", outside); document.addEventListener("keydown", escape, true); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { document.removeEventListener("pointerdown", outside); document.removeEventListener("keydown", escape, true); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; }, [panel]); const manualSelection = (next: ScoreVisibility) => { setPreviousSelection(null); onVisibility(next); }; const metadata = panel && panel !== "choose" ? SCORE_MODELS[panel] : null; return
{previousSelection && }
{selected.map(row => { const model = SCORE_MODELS[row.model]; const point = scoreAt(row.points, currentMs, row.sample_interval_ms * 2); return
onFocus(row.model)} onPointerLeave={() => onFocus(null)}>
; })} {!selected.length && {loading ? "正在载入评分…" : "尚未选择可用模型"}}
{panel && createPortal(, document.body)}
; } export default function ModelScoreTrack({ series, width, durationMs, currentMs, thresholds, annotations, onSeek, onSelectAnnotation, focusedModel }: { series: ScoreSeries[]; width: number; durationMs: number; currentMs: number; thresholds: ScoreThresholds; annotations: TimelineAnnotation[]; focusedModel: ModelId | null; 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 && }
; }