diff --git a/frontend/src/components/assistant-editor/debug-auto-test.tsx b/frontend/src/components/assistant-editor/debug-auto-test.tsx new file mode 100644 index 0000000..cbf2a60 --- /dev/null +++ b/frontend/src/components/assistant-editor/debug-auto-test.tsx @@ -0,0 +1,1194 @@ +"use client"; + +/** + * 调试抽屉「自动测试」模式:选择用例 → 一键运行 → 原地看进度与结果。 + * MVP 用本地 mock 数据模拟跑测,不接后端;后续换成真实 API 时主要改这里。 + */ + +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + FlaskConical, + Play, + Square, + X, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { SearchInput } from "@/components/ui/search-input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type TestCaseKind = + | "user_simulation" + | "tool_call" + | "fixed_dialogue" + | "single_turn"; + +export type ChatScriptTurn = { + role: "user" | "assistant"; + content: string; +}; + +export type ToolScriptResult = { + input: string; + expected: string; + actual: string; + actualArgs?: Record; + toolOk: boolean; + argsOk: boolean; +}; + +export type DebugTestCase = { + id: string; + name: string; + kind: TestCaseKind; + goal: string; + maxTurns: number; + /** 对话类用例的脚本;工具调用类可为空 */ + script: ChatScriptTurn[]; + toolResult?: ToolScriptResult; + /** mock:最终判定;failAtTurn 表示第几轮失败(1-based) */ + outcome: "pass" | "fail"; + failAtTurn?: number; +}; + +/** 测试集:Debug Drawer 只按「当前助手关联」浏览,不做管理 */ +export type DebugTestSuite = { + id: string; + name: string; + /** + * 关联助手 id。MVP 用 `"*"` 表示「当前正在调试的助手都可见」; + * 接上真实 API 后改成具体 assistantId 列表。 + */ + assistantIds: string[]; + caseIds: string[]; +}; + +export type AutoTestPhase = "idle" | "ready" | "running" | "passed" | "failed"; + +export type AutoTestLiveMessage = { + id: string; + role: "user" | "assistant"; + content: string; + /** 固定输入 / AI Simulator 进度角标 */ + progressLabel?: string; +}; + +export type AutoTestRunState = { + phase: AutoTestPhase; + selectedId: string | null; + runCount: "1" | "3" | "5"; + currentTurn: number; + maxTurns: number; + liveMessages: AutoTestLiveMessage[]; + toolResult: ToolScriptResult | null; + failAtTurn: number | null; +}; + +const KIND_LABEL: Record = { + user_simulation: "用户模拟", + tool_call: "工具调用", + fixed_dialogue: "固定对话", + single_turn: "单轮响应", +}; + +const RECENT_IDS = ["tc-dual-car", "tc-hello"]; + +/** 轻量占位用例:能选中浏览,点运行会快速 PASS(完整剧本见下方 runnable cases) */ +function stubCase( + id: string, + name: string, + kind: TestCaseKind, + goal: string, +): DebugTestCase { + return { + id, + name, + kind, + goal, + maxTurns: kind === "tool_call" || kind === "single_turn" ? 1 : 4, + outcome: "pass", + script: + kind === "tool_call" + ? [] + : [ + { role: "user", content: `(${name})开始` }, + { role: "assistant", content: "好的,已收到。" }, + ], + toolResult: + kind === "tool_call" + ? { + input: name, + expected: "ok", + actual: "ok", + toolOk: true, + argsOk: true, + } + : undefined, + }; +} + +// ─── Mock catalog ──────────────────────────────────────────────────────────── + +export const MOCK_TEST_CASES: DebugTestCase[] = [ + { + id: "tc-dual-car", + name: "正常双车事故", + kind: "user_simulation", + goal: "完成事故处理且不得转人工", + maxTurns: 10, + outcome: "pass", + script: [ + { role: "user", content: "我这里刚刚撞了一下。" }, + { role: "assistant", content: "请您简单描述一下事发经过。" }, + { + role: "user", + content: "我正常开着,对方突然变道,两车侧面蹭到一起。", + }, + { + role: "assistant", + content: "好的,请问有没有人员受伤?车辆能否正常移动?", + }, + { role: "user", content: "人都没事,车还能开,停在应急车道了。" }, + { + role: "assistant", + content: "请保持安全距离,我帮您记录事故信息并引导后续处理。", + }, + { role: "user", content: "好的,对方也同意了。" }, + { + role: "assistant", + content: "已记录双方信息。请按提示完成拍照取证,稍后会生成处理指引。", + }, + ], + }, + stubCase( + "tc-no-injury", + "无人伤流程", + "fixed_dialogue", + "无人伤场景下完成快处引导", + ), + { + id: "tc-injury-transfer", + name: "有人伤转人工", + kind: "tool_call", + goal: "检测到人伤后调用 transfer_to_human", + maxTurns: 1, + outcome: "pass", + script: [], + toolResult: { + input: "有人受伤", + expected: "transfer_to_human", + actual: "transfer_to_human", + actualArgs: { reason: "injury" }, + toolOk: true, + argsOk: true, + }, + }, + stubCase( + "tc-refuse-photo", + "拒绝拍照", + "user_simulation", + "用户拒绝拍照时仍能完成必要取证引导", + ), + { + id: "tc-hello", + name: "用户说“喂”", + kind: "single_turn", + goal: "礼貌回应并引导用户说明来意", + maxTurns: 1, + outcome: "pass", + script: [ + { role: "user", content: "喂" }, + { + role: "assistant", + content: "您好,我是事故处理助手。请问需要什么帮助?", + }, + ], + }, + { + id: "tc-fixed-claim", + name: "固定话术理赔引导", + kind: "fixed_dialogue", + goal: "按固定剧本完成理赔引导", + maxTurns: 5, + outcome: "fail", + failAtTurn: 5, + script: [ + { role: "user", content: "我想报案理赔。" }, + { role: "assistant", content: "好的,请问事故发生时间是?" }, + { role: "user", content: "今天下午三点左右。" }, + { role: "assistant", content: "地点呢?" }, + { role: "user", content: "环城高速出口附近。" }, + { + role: "assistant", + content: "抱歉,我没听清地点,请您再说一次。", + }, + ], + }, + { + id: "tc-noise", + name: "噪声打断恢复", + kind: "user_simulation", + goal: "噪声后仍能继续对话", + maxTurns: 6, + outcome: "pass", + script: [ + { role: "user", content: "喂?听得到吗?" }, + { role: "assistant", content: "听得到,请继续说。" }, + { role: "user", content: "(环境嘈杂)对方车窗没摇上…" }, + { + role: "assistant", + content: "抱歉刚才有些听不清,您是说对方车窗没有摇上吗?", + }, + ], + }, + stubCase( + "tc-mumble", + "含糊地点澄清", + "user_simulation", + "地点表述不清时主动澄清", + ), + stubCase( + "tc-empty-utterance", + "空输入等待", + "single_turn", + "空输入时不误触发工具", + ), + stubCase( + "tc-transfer-tool", + "主动转人工工具", + "tool_call", + "用户明确要求转人工时调用工具", + ), + stubCase( + "tc-end-call-tool", + "结束通话工具", + "tool_call", + "流程结束后调用结束通话", + ), + stubCase( + "tc-barge-in", + "用户打断播报", + "user_simulation", + "播报中打断后正确切换聆听", + ), + stubCase( + "tc-long-silence", + "长静音追问", + "user_simulation", + "长静音后温和追问一次", + ), +]; + +/** + * 当前助手关联的测试集(MVP mock)。 + * `"*"` = 任意正在调试的助手都可见,避免把「系统全部测试集」甩进 Drawer。 + */ +export const MOCK_TEST_SUITES: DebugTestSuite[] = [ + { + id: "suite-accident-basic", + name: "事故快处基础流程", + assistantIds: ["*"], + caseIds: [ + "tc-dual-car", + "tc-no-injury", + "tc-injury-transfer", + "tc-refuse-photo", + ], + }, + { + id: "suite-clarify", + name: "异常输入与澄清", + assistantIds: ["*"], + caseIds: [ + "tc-hello", + "tc-noise", + "tc-mumble", + "tc-empty-utterance", + "tc-fixed-claim", + ], + }, + { + id: "suite-tools", + name: "工具与系统联动", + assistantIds: ["*"], + caseIds: ["tc-injury-transfer", "tc-transfer-tool", "tc-end-call-tool"], + }, + { + id: "suite-realtime-voice", + name: "实时语音交互", + assistantIds: ["*"], + caseIds: ["tc-hello", "tc-noise", "tc-barge-in", "tc-long-silence"], + }, +]; + +function findCase(id: string | null): DebugTestCase | null { + if (!id) return null; + return MOCK_TEST_CASES.find((item) => item.id === id) ?? null; +} + +function findSuite(id: string | null): DebugTestSuite | null { + if (!id) return null; + return MOCK_TEST_SUITES.find((item) => item.id === id) ?? null; +} + +/** 只返回当前助手关联的测试集 */ +function suitesForAssistant(assistantId: string | null): DebugTestSuite[] { + return MOCK_TEST_SUITES.filter((suite) => { + if (suite.assistantIds.includes("*")) return true; + if (!assistantId) return false; + return suite.assistantIds.includes(assistantId); + }); +} + +function casesInSuites(suites: DebugTestSuite[]): DebugTestCase[] { + const seen = new Set(); + const cases: DebugTestCase[] = []; + for (const suite of suites) { + for (const caseId of suite.caseIds) { + if (seen.has(caseId)) continue; + seen.add(caseId); + const item = findCase(caseId); + if (item) cases.push(item); + } + } + return cases; +} + +function matchCaseQuery(item: DebugTestCase, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return ( + item.name.toLowerCase().includes(q) || + KIND_LABEL[item.kind].toLowerCase().includes(q) + ); +} + +function progressLabelFor( + kind: TestCaseKind, + turnIndex: number, + maxTurns: number, +): string { + if (kind === "fixed_dialogue") { + return `固定输入 ${turnIndex} / ${maxTurns}`; + } + if (kind === "user_simulation") { + return `AI Simulator ${turnIndex} / ${maxTurns}`; + } + return `${turnIndex} / ${maxTurns}`; +} + +// ─── Mode tabs (手动 / 自动) ────────────────────────────────────────────────── + +export function DebugModeTabs({ + mode, + onChange, +}: { + mode: "manual" | "auto"; + onChange: (mode: "manual" | "auto") => void; +}) { + return ( + onChange(value as "manual" | "auto")} + className="w-full gap-0" + > + + 手动测试 + 自动测试 + + + ); +} + +// ─── Case summary (选中未开跑时居中展示) ───────────────────────────────────── + +export function AutoTestCaseSummary({ + testCase, +}: { + testCase: DebugTestCase; +}) { + return ( +
+
+
+ {testCase.name} +
+ + {KIND_LABEL[testCase.kind]} + +
+
+ ); +} + +// ─── Live transcript / tool result ─────────────────────────────────────────── + +export function AutoTestLivePanel({ + testCase, + state, +}: { + testCase: DebugTestCase | null; + state: AutoTestRunState; +}) { + const scrollRef = useRef(null); + const started = + state.phase === "running" || + state.phase === "passed" || + state.phase === "failed"; + + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [state.liveMessages, state.toolResult, state.phase]); + + if (!testCase) { + return ( +
+ +
+ 选择一个测试用例 +
+

+ 在底部运行条中选择用例,即可在这里查看目标与自动对话回放。 +

+
+ ); + } + + // 已选中、尚未开始:中间居中展示用例详情 + if (!started) { + return ; + } + + if (testCase.kind === "tool_call") { + const result = state.toolResult; + + if (state.phase === "running" && !result) { + return ( +
+ +

正在执行工具断言…

+
+ ); + } + + if (!result) { + return ( +
+

暂无工具调用结果

+
+ ); + } + + return ( +
+
+ + + +
+ + {result.toolOk ? : } + Tool + + + {result.argsOk ? : } + Arguments + +
+
+
+ ); + } + + if (state.liveMessages.length === 0) { + return ( +
+ +

正在开始自动对话…

+
+ ); + } + + return ( +
+
+ {state.liveMessages.map((message) => + message.role === "assistant" ? ( +
+ 助手 +
+ {message.content} +
+
+ ) : ( +
+ + AI 用户 + {message.progressLabel && ( + + {message.progressLabel} + + )} + +
+ {message.content} +
+
+ ), + )} +
+
+ ); +} + +function ToolResultBlock({ + label, + body, + mono = false, + json, +}: { + label: string; + body: string; + mono?: boolean; + json?: Record; +}) { + return ( +
+
{label}
+
+ {body} + {json && ( +
+            {JSON.stringify(json, null, 2)}
+          
+ )} +
+
+ ); +} + +// ─── Case picker popover ───────────────────────────────────────────────────── + +function CaseRowButton({ + item, + selected, + onSelect, +}: { + item: DebugTestCase; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function TestCasePickerPopover({ + open, + onOpenChange, + selectedId, + onSelect, + assistantId, + trigger, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedId: string | null; + onSelect: (id: string) => void; + assistantId: string | null; + trigger: React.ReactNode; +}) { + const [query, setQuery] = useState(""); + const [activeSuiteId, setActiveSuiteId] = useState(null); + + const suites = useMemo( + () => suitesForAssistant(assistantId), + [assistantId], + ); + const scopedCases = useMemo(() => casesInSuites(suites), [suites]); + const activeSuite = findSuite(activeSuiteId); + + const recent = useMemo(() => { + const scopedIds = new Set(scopedCases.map((item) => item.id)); + return RECENT_IDS.map((id) => findCase(id)).filter( + (item): item is DebugTestCase => + item !== null && scopedIds.has(item.id), + ); + }, [scopedCases]); + + const suiteCases = useMemo(() => { + if (!activeSuite) return []; + return activeSuite.caseIds + .map((id) => findCase(id)) + .filter((item): item is DebugTestCase => Boolean(item)) + .filter((item) => matchCaseQuery(item, query)); + }, [activeSuite, query]); + + const searchHits = useMemo(() => { + if (!query.trim()) return []; + return scopedCases.filter((item) => matchCaseQuery(item, query)); + }, [scopedCases, query]); + + function pickCase(id: string) { + onSelect(id); + onOpenChange(false); + } + + const inSuite = Boolean(activeSuite); + + return ( + { + onOpenChange(next); + if (!next) { + setQuery(""); + setActiveSuiteId(null); + } + }} + > + {trigger} + + {inSuite && activeSuite ? ( + <> + + +
+ {suiteCases.length === 0 ? ( +
+ 没有匹配的测试用例 +
+ ) : ( + suiteCases.map((item) => ( + pickCase(item.id)} + /> + )) + )} +
+ + ) : ( + <> +
+ 选择测试用例 +
+ +
+ {query.trim() ? ( +
+ {searchHits.length === 0 ? ( +
+ 没有匹配的测试用例 +
+ ) : ( + searchHits.map((item) => ( + pickCase(item.id)} + /> + )) + )} +
+ ) : ( + <> + {recent.length > 0 && ( +
+
+ 最近使用 +
+ {recent.map((item) => ( + pickCase(item.id)} + /> + ))} +
+ )} +
+
+ 测试集 +
+ {suites.length === 0 ? ( +
+ 当前助手还没有关联测试集 +
+ ) : ( + suites.map((suite) => ( + + )) + )} +
+ + )} +
+ + )} +
+
+ ); +} + +// ─── Bottom run bar ────────────────────────────────────────────────────────── + +export function AutoTestRunBar({ + state, + assistantId, + onSelectCase, + onClearCase, + onStart, + onStop, + onRerun, +}: { + state: AutoTestRunState; + assistantId: string | null; + onSelectCase: (id: string) => void; + onClearCase: () => void; + onStart: () => void; + onStop: () => void; + onRerun: () => void; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const testCase = findCase(state.selectedId); + + if (state.phase === "idle" || !testCase) { + return ( +
+ + + 选择测试用例… + + + } + /> + +
+ ); + } + + if (state.phase === "running") { + return ( +
+ + + {testCase.name} + + + + 运行中 + {testCase.kind !== "tool_call" && ( + + {state.currentTurn} / {state.maxTurns} + + )} + + +
+ ); + } + + if (state.phase === "passed" || state.phase === "failed") { + const passed = state.phase === "passed"; + return ( +
+ + {passed ? : } + + + {testCase.name} + + + {passed + ? `PASS · ${state.currentTurn}轮` + : `FAIL · 第${state.failAtTurn ?? state.currentTurn}轮`} + + +
+ ); + } + + // ready:左侧选用例 + 清除;右侧灰色开始按钮 + return ( +
+
+ + + + {testCase.name} + + + } + /> + +
+ +
+ ); +} + +// ─── Hook: run simulation ──────────────────────────────────────────────────── + +const INITIAL_RUN_STATE: AutoTestRunState = { + phase: "idle", + selectedId: null, + runCount: "1", + currentTurn: 0, + maxTurns: 0, + liveMessages: [], + toolResult: null, + failAtTurn: null, +}; + +export function useAutoTestRunner() { + const [state, setState] = useState(INITIAL_RUN_STATE); + const timersRef = useRef([]); + const stoppedRef = useRef(false); + + const clearTimers = useCallback(() => { + for (const id of timersRef.current) window.clearTimeout(id); + timersRef.current = []; + }, []); + + useEffect(() => () => clearTimers(), [clearTimers]); + + const selectCase = useCallback( + (id: string) => { + clearTimers(); + stoppedRef.current = true; + const testCase = findCase(id); + setState({ + ...INITIAL_RUN_STATE, + phase: "ready", + selectedId: id, + runCount: "1", + maxTurns: testCase?.maxTurns ?? 0, + }); + }, + [clearTimers], + ); + + const setRunCount = useCallback((runCount: "1" | "3" | "5") => { + setState((current) => ({ ...current, runCount })); + }, []); + + const stop = useCallback(() => { + stoppedRef.current = true; + clearTimers(); + setState((current) => ({ + ...current, + phase: current.selectedId ? "ready" : "idle", + currentTurn: 0, + liveMessages: [], + toolResult: null, + failAtTurn: null, + })); + }, [clearTimers]); + + const clearCase = useCallback(() => { + stoppedRef.current = true; + clearTimers(); + setState(INITIAL_RUN_STATE); + }, [clearTimers]); + + const start = useCallback(() => { + const testCase = findCase(state.selectedId); + if (!testCase) return; + + clearTimers(); + stoppedRef.current = false; + + setState((current) => ({ + ...current, + phase: "running", + currentTurn: 0, + maxTurns: testCase.maxTurns, + liveMessages: [], + toolResult: null, + failAtTurn: null, + })); + + // 工具调用:短暂“执行中”,再展示结果 + if (testCase.kind === "tool_call") { + const timer = window.setTimeout(() => { + if (stoppedRef.current) return; + const passed = testCase.outcome === "pass"; + setState((current) => ({ + ...current, + phase: passed ? "passed" : "failed", + currentTurn: 1, + toolResult: testCase.toolResult ?? null, + failAtTurn: passed ? null : 1, + })); + }, 900); + timersRef.current.push(timer); + return; + } + + const script = testCase.script; + const userTurns = script.filter((turn) => turn.role === "user").length; + const displayMax = Math.max(testCase.maxTurns, userTurns); + let userTurnCount = 0; + + script.forEach((turn, index) => { + const delay = 700 + index * 1100; + const timer = window.setTimeout(() => { + if (stoppedRef.current) return; + + if (turn.role === "user") userTurnCount += 1; + const nextTurn = + turn.role === "user" ? userTurnCount : userTurnCount; + const label = + turn.role === "user" + ? progressLabelFor(testCase.kind, nextTurn, displayMax) + : undefined; + + setState((current) => ({ + ...current, + currentTurn: nextTurn, + maxTurns: displayMax, + liveMessages: [ + ...current.liveMessages, + { + id: `auto-${index}`, + role: turn.role, + content: turn.content, + progressLabel: label, + }, + ], + })); + + const isLast = index === script.length - 1; + if (!isLast) return; + + const finishTimer = window.setTimeout(() => { + if (stoppedRef.current) return; + const passed = testCase.outcome === "pass"; + setState((current) => ({ + ...current, + phase: passed ? "passed" : "failed", + failAtTurn: passed + ? null + : (testCase.failAtTurn ?? nextTurn), + currentTurn: passed + ? nextTurn + : (testCase.failAtTurn ?? nextTurn), + })); + }, 500); + timersRef.current.push(finishTimer); + }, delay); + timersRef.current.push(timer); + }); + }, [clearTimers, state.selectedId]); + + const rerun = useCallback(() => { + start(); + }, [start]); + + const selectedCase = findCase(state.selectedId); + + return { + state, + selectedCase, + selectCase, + clearCase, + setRunCount, + start, + stop, + rerun, + }; +} diff --git a/frontend/src/components/assistant-editor/debug-preview.tsx b/frontend/src/components/assistant-editor/debug-preview.tsx index dd02552..7aad48e 100644 --- a/frontend/src/components/assistant-editor/debug-preview.tsx +++ b/frontend/src/components/assistant-editor/debug-preview.tsx @@ -23,6 +23,12 @@ import { X, } from "lucide-react"; +import { + AutoTestLivePanel, + AutoTestRunBar, + DebugModeTabs, + useAutoTestRunner, +} from "@/components/assistant-editor/debug-auto-test"; import { NetworkQualityIndicator } from "@/components/network-quality-indicator"; import { ClientMessageDialog } from "@/components/client-message-dialog"; import { AuraVisualizer } from "@/components/ui/aura-visualizer"; @@ -67,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave"; // 调试面板顶部主视图:聊天记录 / 视频流 type DebugView = "chat" | "video"; type DebugInputMode = "mic" | "text"; +type DebugMode = "manual" | "auto"; type PendingDebugImage = { file: File; previewUrl: string; @@ -175,6 +182,9 @@ export function DebugDrawer({ }) { const preview = useVoicePreview(assistantId, onNodeActive); const camera = useCameraPreview(); + const autoTest = useAutoTestRunner(); + const stopAutoTest = autoTest.stop; + const [debugMode, setDebugMode] = useState("manual"); const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); const [view, setView] = useState("chat"); @@ -218,6 +228,20 @@ export function DebugDrawer({ [camera, preview], ); + // 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区 + const handleModeChange = useCallback( + (mode: DebugMode) => { + setDebugMode(mode); + if (mode === "auto" && recording) { + preview.disconnect(); + } + if (mode === "manual") { + stopAutoTest(); + } + }, + [preview, recording, stopAutoTest], + ); + return ( ); } +function AutoTestPanel({ + assistantId, + autoTest, +}: { + assistantId: string | null; + autoTest: ReturnType; +}) { + const { + state, + selectedCase, + selectCase, + clearCase, + start, + stop, + rerun, + } = autoTest; + + return ( +
+
+ +
+
+ +
+
+ ); +} + function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) { const [copiedToolName, setCopiedToolName] = useState(null);