feat: mock continuous voice test editor
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -52,9 +52,9 @@ const FIXED_SCRIPT_OPTIONS: {
|
||||
{
|
||||
value: "fixed_script_continuous_voice",
|
||||
title: "连续语音",
|
||||
description: "按指定时序连续发送语音",
|
||||
description: "按原始时间轴连续回放音频",
|
||||
icon: AudioLines,
|
||||
available: false,
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -705,7 +705,7 @@ export function NextReplyEditorBody({
|
||||
);
|
||||
}
|
||||
|
||||
function OverallCriteriaFields({
|
||||
export function OverallCriteriaFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user