为 EOT Studio 增加可展开的模型分数时间轴
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user