diff --git a/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx b/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx
new file mode 100644
index 0000000..63ded55
--- /dev/null
+++ b/frontend/src/components/test-cases/continuous-voice-editor-mock.tsx
@@ -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
{
+ 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(null);
+ const fileInputRef = useRef(null);
+ const audioRef = useRef(null);
+ const objectUrlRef = useRef(null);
+ const sectionRefs = useRef>({
+ sample: null,
+ segments: null,
+ overall: null,
+ });
+
+ const [activeSection, setActiveSection] =
+ useState("sample");
+ const [sampleName, setSampleName] = useState("事故咨询连续语音.wav");
+ const [sampleSize, setSampleSize] = useState(1_840_000);
+ const [sampleIsDemo, setSampleIsDemo] = useState(true);
+ const [audioUrl, setAudioUrl] = useState(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) {
+ 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 (
+
+
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"
+ />
+
+
+
+
+
+ 前端 Mock
+
+ 用于确认编辑流程;音频和片段修改不会保存,也还不能进入批量运行。
+
+
+
{
+ sectionRefs.current.sample = element;
+ }}
+ className="scroll-mt-3"
+ >
+ }
+ title="连续语音样本"
+ description="按原始时间轴 1× 回放;助手播报期间仍继续输入"
+ action={
+
+ }
+ >
+ void selectAudio(event.target.files?.[0])}
+ />
+
+
+
{
+ sectionRefs.current.segments = element;
+ }}
+ className="scroll-mt-3"
+ >
+ }
+ title="参考片段与观察点"
+ description="片段用于对齐参考转写和预期,不会改变原始音频时间轴"
+ action={
+
+ }
+ >
+
+ {segments.map((segment, index) => {
+ const expanded = expandedSegmentId === segment.id;
+ return (
+
+
+
+
+
+
+ {expanded && (
+
+
+
+ updateSegment(segment.id, { startMs })
+ }
+ />
+ updateSegment(segment.id, { endMs })}
+ />
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+
{
+ sectionRefs.current.overall = element;
+ }}
+ className="scroll-mt-3"
+ >
+ }
+ title="整体评估标准"
+ description="对完整语音时间线、助手回复和工具调用进行综合判断"
+ >
+
+
+
+
+
+
+ );
+}
+
+function PlaybackRule({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function TimeInput({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ onChange: (value: number) => void;
+}) {
+ return (
+
+ );
+}
+
+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 (
+
+
+
+ {ticks.map((tick) => (
+
+ {tick.label}
+
+ ))}
+
+
+
+ {levels.map((level, index) => (
+
+ ))}
+
+
+ {progress > 0 && (
+
+ )}
+
+
+ {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 (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/test-cases/input-mode-picker.tsx b/frontend/src/components/test-cases/input-mode-picker.tsx
index cc17882..6f2de90 100644
--- a/frontend/src/components/test-cases/input-mode-picker.tsx
+++ b/frontend/src/components/test-cases/input-mode-picker.tsx
@@ -52,9 +52,9 @@ const FIXED_SCRIPT_OPTIONS: {
{
value: "fixed_script_continuous_voice",
title: "连续语音",
- description: "按指定时序连续发送语音",
+ description: "按原始时间轴连续回放音频",
icon: AudioLines,
- available: false,
+ available: true,
},
];
diff --git a/frontend/src/components/test-cases/next-reply-editor.tsx b/frontend/src/components/test-cases/next-reply-editor.tsx
index 5e13571..daef98a 100644
--- a/frontend/src/components/test-cases/next-reply-editor.tsx
+++ b/frontend/src/components/test-cases/next-reply-editor.tsx
@@ -705,7 +705,7 @@ export function NextReplyEditorBody({
);
}
-function OverallCriteriaFields({
+export function OverallCriteriaFields({
value,
onChange,
}: {
diff --git a/frontend/src/data/test-suites.ts b/frontend/src/data/test-suites.ts
index ccd0889..3c6a22a 100644
--- a/frontend/src/data/test-suites.ts
+++ b/frontend/src/data/test-suites.ts
@@ -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) {