feat: mock continuous voice test editor

This commit is contained in:
Xin Wang
2026-08-10 14:46:29 +08:00
parent 19e8c8c108
commit cf32ea605d
5 changed files with 758 additions and 4 deletions

View File

@@ -32,6 +32,7 @@ import {
} from "@/components/layout/list-page-layout";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import { InputModePicker } from "@/components/test-cases/input-mode-picker";
import { ContinuousVoiceEditorMock } from "@/components/test-cases/continuous-voice-editor-mock";
import {
NextReplyEditorBody,
type CaseEditorDraft,
@@ -534,6 +535,8 @@ function SuiteDetailView({
const validationMessage = draft
? getTestCaseValidationMessage(draft)
: "请先选择测试用例";
const isContinuousVoicePreview =
draft?.inputMode === "fixed_script_continuous_voice";
const canSave =
Boolean(draft?.name.trim()) &&
dirty &&
@@ -869,7 +872,11 @@ function SuiteDetailView({
/>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty && validationMessage ? (
{isContinuousVoicePreview ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
·
</span>
) : dirty && validationMessage ? (
<span
className="max-w-56 truncate text-xs text-destructive"
title={validationMessage}
@@ -916,6 +923,8 @@ function SuiteDetailView({
{draft.inputMode === "fixed_script_text" ? (
<NextReplyEditorBody draft={draft} onChange={setDraft} />
) : draft.inputMode === "fixed_script_continuous_voice" ? (
<ContinuousVoiceEditorMock draft={draft} />
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<div className="text-sm font-medium text-foreground">

View File

@@ -0,0 +1,742 @@
"use client";
/** Frontend-only interaction mock for fixed, real-time audio replay test cases. */
import {
AudioLines,
ChevronDown,
Clock3,
FileAudio,
Pause,
Play,
Plus,
Settings2,
Target,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { SectionCard } from "@/components/editor/section-card";
import {
OverallCriteriaFields,
type CaseEditorDraft,
} from "@/components/test-cases/next-reply-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type VoiceSectionId = "sample" | "segments" | "overall";
type VoiceSegmentDraft = {
id: string;
startMs: number;
endMs: number;
transcript: string;
asrExpected: string;
responseExpected: string;
toolExpected: string;
maxLatencyMs: number | null;
};
const VOICE_SECTIONS = [
{ id: "sample", label: "语音样本" },
{ id: "segments", label: "参考片段" },
{ id: "overall", label: "整体评估" },
] as const;
const DEMO_DURATION_MS = 28_400;
const DEMO_WAVEFORM = Array.from({ length: 144 }, (_, index) => {
const speechWindow =
(index >= 6 && index <= 27) ||
(index >= 47 && index <= 73) ||
(index >= 92 && index <= 119);
if (!speechWindow) return 0.08 + ((index * 13) % 5) * 0.012;
return Math.min(
1,
0.22 + Math.abs(Math.sin(index * 0.71)) * 0.62 + ((index * 7) % 9) * 0.015,
);
});
const DEMO_SEGMENTS: VoiceSegmentDraft[] = [
{
id: "voice-segment-1",
startMs: 1_200,
endMs: 4_800,
transcript: "你好,我想咨询一下事故处理。",
asrExpected: "应识别出“事故处理”",
responseExpected: "助手应主动询问是否有人受伤,并引导用户说明事故情况。",
toolExpected: "",
maxLatencyMs: 2_000,
},
{
id: "voice-segment-2",
startMs: 9_600,
endMs: 14_100,
transcript: "对方车辆撞了我,但是没有人受伤。",
asrExpected: "应识别出“没有人受伤”",
responseExpected: "助手应进入无人伤事故处理流程,不应建议拨打急救电话。",
toolExpected: "",
maxLatencyMs: 2_000,
},
{
id: "voice-segment-3",
startMs: 18_400,
endMs: 22_700,
transcript: "我应该先拍照还是先挪车?",
asrExpected: "应识别拍照与挪车的先后问题",
responseExpected: "助手应说明先确保安全、记录现场,再按条件移车。",
toolExpected: "",
maxLatencyMs: 2_500,
},
];
function formatTime(ms: number): string {
const seconds = Math.max(0, ms) / 1000;
const minutes = Math.floor(seconds / 60);
const remainder = seconds - minutes * 60;
return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
async function decodeWaveform(file: File, buckets = 144): Promise<number[]> {
const AudioContextClass = window.AudioContext;
const context = new AudioContextClass();
try {
const buffer = await context.decodeAudioData(await file.arrayBuffer());
const channel = buffer.getChannelData(0);
const bucketSize = Math.max(1, Math.floor(channel.length / buckets));
return Array.from({ length: buckets }, (_, bucketIndex) => {
const start = bucketIndex * bucketSize;
const end = Math.min(channel.length, start + bucketSize);
let peak = 0;
for (let index = start; index < end; index += 1) {
peak = Math.max(peak, Math.abs(channel[index]));
}
return Math.max(0.04, Math.min(1, peak));
});
} finally {
await context.close();
}
}
export function ContinuousVoiceEditorMock({
draft,
}: {
draft: CaseEditorDraft;
}) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const objectUrlRef = useRef<string | null>(null);
const sectionRefs = useRef<Record<VoiceSectionId, HTMLElement | null>>({
sample: null,
segments: null,
overall: null,
});
const [activeSection, setActiveSection] =
useState<VoiceSectionId>("sample");
const [sampleName, setSampleName] = useState("事故咨询连续语音.wav");
const [sampleSize, setSampleSize] = useState(1_840_000);
const [sampleIsDemo, setSampleIsDemo] = useState(true);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [durationMs, setDurationMs] = useState(DEMO_DURATION_MS);
const [currentMs, setCurrentMs] = useState(0);
const [playing, setPlaying] = useState(false);
const [waveform, setWaveform] = useState(DEMO_WAVEFORM);
const [fileError, setFileError] = useState("");
const [segments, setSegments] = useState(DEMO_SEGMENTS);
const [overallCriteria, setOverallCriteria] = useState(
draft.overallCriteria,
);
const [expandedSegmentId, setExpandedSegmentId] = useState(
DEMO_SEGMENTS[0].id,
);
const [tailWaitSeconds, setTailWaitSeconds] = useState("8");
const [timeoutSeconds, setTimeoutSeconds] = useState("90");
useEffect(
() => () => {
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
},
[],
);
const timelineTicks = useMemo(() => {
const tickCount = 4;
return Array.from({ length: tickCount + 1 }, (_, index) => ({
left: `${(index / tickCount) * 100}%`,
label: formatTime((durationMs * index) / tickCount),
}));
}, [durationMs]);
function scrollToSection(sectionId: VoiceSectionId) {
const container = scrollContainerRef.current;
const section = sectionRefs.current[sectionId];
if (!container || !section) return;
setActiveSection(sectionId);
container.scrollTo({
top:
container.scrollTop +
section.getBoundingClientRect().top -
container.getBoundingClientRect().top,
behavior: "smooth",
});
}
async function selectAudio(file: File | undefined) {
if (!file) return;
setFileError("");
setPlaying(false);
setCurrentMs(0);
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
const url = URL.createObjectURL(file);
objectUrlRef.current = url;
setAudioUrl(url);
setSampleName(file.name);
setSampleSize(file.size);
setSampleIsDemo(false);
try {
setWaveform(await decodeWaveform(file));
} catch {
setWaveform(DEMO_WAVEFORM);
setFileError("浏览器暂时无法解析此格式,已保留示意波形。后端接入后会统一转码。");
}
}
async function togglePlayback() {
const audio = audioRef.current;
if (!audio || !audioUrl) return;
if (playing) {
audio.pause();
return;
}
await audio.play();
}
function updateSegment(id: string, patch: Partial<VoiceSegmentDraft>) {
setSegments((current) =>
current.map((segment) =>
segment.id === id ? { ...segment, ...patch } : segment,
),
);
}
function addSegment() {
const previous = segments[segments.length - 1];
const startMs = Math.min(durationMs, (previous?.endMs ?? 0) + 1_000);
const next: VoiceSegmentDraft = {
id: `voice-segment-${Date.now()}`,
startMs,
endMs: Math.min(durationMs, startMs + 3_000),
transcript: "",
asrExpected: "",
responseExpected: "",
toolExpected: "",
maxLatencyMs: 2_000,
};
setSegments((current) => [...current, next]);
setExpandedSegmentId(next.id);
}
function removeSegment(id: string) {
setSegments((current) => current.filter((segment) => segment.id !== id));
setExpandedSegmentId((current) => (current === id ? "" : current));
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SectionAnchorTabs
ariaLabel="连续语音测试用例分区"
sections={VOICE_SECTIONS}
activeSectionId={activeSection}
onSelect={(sectionId) => scrollToSection(sectionId as VoiceSectionId)}
className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8"
contentClassName="mx-auto w-full max-w-3xl"
/>
<div
ref={scrollContainerRef}
className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10"
>
<div className="mx-auto max-w-3xl space-y-3">
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs leading-5 text-muted-foreground">
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 font-medium text-amber-600 dark:text-amber-400">
Mock
</span>
</div>
<section
ref={(element) => {
sectionRefs.current.sample = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<AudioLines size={15} />}
title="连续语音样本"
description="按原始时间轴 1× 回放;助手播报期间仍继续输入"
action={
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1.5 border-hairline-strong text-xs text-muted-foreground"
onClick={() => fileInputRef.current?.click()}
>
<Upload size={13} />
</Button>
}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*,.wav,.mp3,.m4a,.aac,.ogg"
className="hidden"
onChange={(event) => void selectAudio(event.target.files?.[0])}
/>
<audio
ref={audioRef}
src={audioUrl ?? undefined}
onLoadedMetadata={(event) =>
setDurationMs(Math.round(event.currentTarget.duration * 1000))
}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setCurrentMs(0);
}}
onTimeUpdate={(event) =>
setCurrentMs(Math.round(event.currentTarget.currentTime * 1000))
}
/>
<div className="flex flex-col gap-3 rounded-xl border border-hairline bg-canvas-soft/40 p-3 sm:flex-row sm:items-center">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<FileAudio size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">
{sampleName}
</p>
{sampleIsDemo && (
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-soft">
{formatTime(durationMs)} · {formatFileSize(sampleSize)} ·
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5 rounded-full border-hairline-strong text-xs"
disabled={!audioUrl}
title={audioUrl ? undefined : "示例样本不包含真实音频,请先上传文件"}
onClick={() => void togglePlayback()}
>
{playing ? <Pause size={13} /> : <Play size={13} />}
{playing ? "暂停" : "试听"}
</Button>
</div>
{fileError && (
<p className="text-xs text-amber-600 dark:text-amber-400" role="status">
{fileError}
</p>
)}
<VoiceWaveform
levels={waveform}
durationMs={durationMs}
currentMs={currentMs}
segments={segments}
ticks={timelineTicks}
onSelectSegment={setExpandedSegmentId}
/>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<PlaybackRule label="1× 真实时间" />
<PlaybackRule label="不等待助手回复" />
<PlaybackRule label="允许语音打断" />
<PlaybackRule label="保留原始静音" />
</div>
<details className="group border-t border-hairline pt-3">
<summary className="flex w-fit cursor-pointer list-none items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-canvas-soft hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
<Settings2 size={13} />
<ChevronDown
size={13}
className="transition-transform group-open:rotate-180"
/>
</summary>
<div className="mt-3 grid gap-4 rounded-xl border border-hairline bg-canvas-soft/40 p-3 sm:grid-cols-2">
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<div className="relative">
<Input
type="number"
min={1}
max={60}
value={tailWaitSeconds}
onChange={(event) => setTailWaitSeconds(event.target.value)}
className="h-8 border-hairline-strong bg-background pr-9 text-xs"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[11px] text-muted-soft">
</span>
</div>
</label>
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={timeoutSeconds}
onChange={(event) => setTimeoutSeconds(event.target.value)}
className="h-8 border-hairline-strong bg-background pr-9 text-xs"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[11px] text-muted-soft">
</span>
</div>
</label>
<p className="text-[11px] leading-5 text-muted-soft sm:col-span-2">
</p>
</div>
</details>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.segments = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<Clock3 size={15} />}
title="参考片段与观察点"
description="片段用于对齐参考转写和预期,不会改变原始音频时间轴"
action={
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
onClick={addSegment}
>
<Plus size={13} />
</Button>
}
>
<div className="space-y-2.5">
{segments.map((segment, index) => {
const expanded = expandedSegmentId === segment.id;
return (
<div
key={segment.id}
className="rounded-xl border border-hairline bg-canvas-soft/30"
>
<div className="flex items-center gap-2 px-3 py-2.5">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={expanded}
onClick={() => setExpandedSegmentId(expanded ? "" : segment.id)}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-strong text-[11px] font-medium text-foreground">
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-foreground">
{segment.transcript || "未填写参考文本"}
</span>
<span className="mt-0.5 block text-[11px] tabular-nums text-muted-soft">
{formatTime(segment.startMs)}{formatTime(segment.endMs)} ·
</span>
</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 text-muted-soft transition-transform",
expanded && "rotate-180",
)}
/>
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-7 w-7 shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeSegment(segment.id)}
aria-label={`删除参考片段 ${index + 1}`}
>
<Trash2 size={13} />
</Button>
</div>
{expanded && (
<div className="space-y-3 border-t border-hairline px-3 py-3">
<div className="grid gap-3 sm:grid-cols-[120px_120px_minmax(0,1fr)]">
<TimeInput
label="开始时间"
value={segment.startMs}
onChange={(startMs) =>
updateSegment(segment.id, { startMs })
}
/>
<TimeInput
label="结束时间"
value={segment.endMs}
onChange={(endMs) => updateSegment(segment.id, { endMs })}
/>
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<Input
value={segment.transcript}
onChange={(event) =>
updateSegment(segment.id, {
transcript: event.target.value,
})
}
placeholder="填写该语音片段的参考文本"
className="h-8 border-hairline-strong bg-background text-xs"
/>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5 text-xs text-muted-foreground">
<span>ASR </span>
<Textarea
value={segment.asrExpected}
onChange={(event) =>
updateSegment(segment.id, {
asrExpected: event.target.value,
})
}
rows={2}
placeholder="例如:应识别出订单号和退款诉求"
className="field-sizing-fixed min-h-[58px] resize-y border-hairline-strong bg-background text-xs"
/>
</label>
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<Textarea
value={segment.responseExpected}
onChange={(event) =>
updateSegment(segment.id, {
responseExpected: event.target.value,
})
}
rows={2}
placeholder="描述该观察点之后应出现的回复行为"
className="field-sizing-fixed min-h-[58px] resize-y border-hairline-strong bg-background text-xs"
/>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<Input
value={segment.toolExpected}
onChange={(event) =>
updateSegment(segment.id, {
toolExpected: event.target.value,
})
}
placeholder="例如transfer_to_human"
className="h-8 border-hairline-strong bg-background font-mono text-xs"
/>
</label>
<label className="space-y-1.5 text-xs text-muted-foreground">
<span></span>
<div className="relative">
<Input
type="number"
min={0}
value={segment.maxLatencyMs ?? ""}
onChange={(event) =>
updateSegment(segment.id, {
maxLatencyMs: event.target.value
? Number(event.target.value)
: null,
})
}
className="h-8 border-hairline-strong bg-background pr-10 text-xs"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[11px] text-muted-soft">
ms
</span>
</div>
</label>
</div>
</div>
)}
</div>
);
})}
</div>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.overall = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<Target size={15} />}
title="整体评估标准"
description="对完整语音时间线、助手回复和工具调用进行综合判断"
>
<OverallCriteriaFields
value={overallCriteria}
onChange={setOverallCriteria}
/>
</SectionCard>
</section>
</div>
</div>
</div>
);
}
function PlaybackRule({ label }: { label: string }) {
return (
<span className="rounded-full border border-hairline bg-canvas-soft/50 px-2.5 py-1">
{label}
</span>
);
}
function TimeInput({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<label className="space-y-1.5 text-xs text-muted-foreground">
<span>{label}</span>
<div className="relative">
<Input
type="number"
min={0}
step={0.1}
value={(value / 1000).toFixed(1)}
onChange={(event) =>
onChange(Math.max(0, Number(event.target.value) * 1000))
}
className="h-8 border-hairline-strong bg-background pr-8 text-xs"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[11px] text-muted-soft">
</span>
</div>
</label>
);
}
function VoiceWaveform({
levels,
durationMs,
currentMs,
segments,
ticks,
onSelectSegment,
}: {
levels: number[];
durationMs: number;
currentMs: number;
segments: VoiceSegmentDraft[];
ticks: { left: string; label: string }[];
onSelectSegment: (id: string) => void;
}) {
const progress = durationMs > 0 ? Math.min(100, (currentMs / durationMs) * 100) : 0;
return (
<div className="overflow-hidden rounded-xl border border-hairline bg-background">
<div className="relative h-28 px-3 pb-7 pt-6">
<div className="absolute inset-x-3 top-1 h-4 text-[10px] text-muted-soft">
{ticks.map((tick) => (
<span
key={tick.left}
className="absolute -translate-x-1/2 tabular-nums first:translate-x-0 last:-translate-x-full"
style={{ left: tick.left }}
>
{tick.label}
</span>
))}
</div>
<div className="flex h-full items-center gap-px overflow-hidden">
{levels.map((level, index) => (
<span
key={index}
className="min-w-px flex-1 rounded-full bg-muted-foreground/45"
style={{ height: `${Math.max(6, level * 100)}%` }}
aria-hidden
/>
))}
</div>
{progress > 0 && (
<span
className="pointer-events-none absolute bottom-7 top-6 w-px bg-foreground/70"
style={{ left: `calc(0.75rem + (100% - 1.5rem) * ${progress / 100})` }}
aria-hidden
/>
)}
<div className="absolute inset-x-3 bottom-1 h-5">
{segments.map((segment, index) => {
const left = durationMs > 0 ? (segment.startMs / durationMs) * 100 : 0;
const width =
durationMs > 0
? ((segment.endMs - segment.startMs) / durationMs) * 100
: 0;
return (
<button
key={segment.id}
type="button"
className="absolute flex h-5 min-w-5 items-center justify-center rounded border border-hairline-strong bg-surface-strong/90 text-[9px] font-medium text-foreground transition-colors hover:bg-surface-strong"
style={{ left: `${left}%`, width: `${Math.max(2, width)}%` }}
title={`片段 ${index + 1}${segment.transcript}`}
aria-label={`展开参考片段 ${index + 1}`}
onClick={() => onSelectSegment(segment.id)}
>
{index + 1}
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -52,9 +52,9 @@ const FIXED_SCRIPT_OPTIONS: {
{
value: "fixed_script_continuous_voice",
title: "连续语音",
description: "按指定时序连续发送语音",
description: "按原始时间轴连续回放音频",
icon: AudioLines,
available: false,
available: true,
},
];

View File

@@ -705,7 +705,7 @@ export function NextReplyEditorBody({
);
}
function OverallCriteriaFields({
export function OverallCriteriaFields({
value,
onChange,
}: {

View File

@@ -143,6 +143,9 @@ export function getTestCaseValidationMessage(
>,
): string | null {
if (item.inputMode !== "fixed_script_text") {
if (item.inputMode === "fixed_script_continuous_voice") {
return "连续语音当前仅支持前端预览,暂不可保存";
}
return "当前输入模式尚未开放";
}
if (item.turns.length === 0) {