import React, { useState, useRef } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Settings2, Play, Download, Plus, Trash2, CheckCircle2, Loader2, ChevronRight, AlertCircle, Globe, ShieldCheck, Slash, Eye, Key, Activity, Clock, Hash, Coins, AlertTriangle } from 'lucide-react'; import yaml from 'js-yaml'; import * as XLSX from 'xlsx'; import { toast } from 'sonner'; import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { DifyInput, TestCase, DifyConfig, AppMode, NodeTraceStep, NodeStatus, TestResult } from './types'; // Parse a Dify SSE stream (workflow/advanced-chat) into a TestResult. // Emits live updates via onUpdate after each meaningful event so the UI // can reflect node-level progress as it arrives. async function consumeDifyStream( body: ReadableStream, appMode: AppMode, signal: AbortSignal, onUpdate: (partial: TestResult) => void ): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const trace: NodeTraceStep[] = []; const traceByExecId = new Map(); let answer = ''; let workflowRunId: string | undefined; let taskId: string | undefined; let messageId: string | undefined; let finalStatus: NodeStatus | string | undefined; let finalOutputs: any; let totalTokens: number | undefined; let elapsedTime: number | undefined; let streamError: string | undefined; const snapshot = (): TestResult => ({ data: finalOutputs !== undefined ? { outputs: finalOutputs } : undefined, answer: appMode === 'chat' ? answer : undefined, task_id: taskId, id: messageId, workflowRunId, trace: trace.length ? [...trace] : undefined, finalStatus, totalTokens, elapsedTime, error: streamError }); const handleEvent = (evt: any) => { const data = evt?.data ?? {}; taskId = taskId || evt?.task_id; workflowRunId = workflowRunId || evt?.workflow_run_id || data?.workflow_run_id; switch (evt?.event) { case 'workflow_started': { trace.length = 0; traceByExecId.clear(); workflowRunId = data.id || workflowRunId; break; } case 'node_started': { const step: NodeTraceStep = { execId: data.id, nodeId: data.node_id, nodeType: data.node_type, title: data.title ?? data.node_id, index: typeof data.index === 'number' ? data.index : trace.length + 1, status: 'running', inputs: data.inputs, startedAt: data.created_at }; traceByExecId.set(step.execId, step); trace.push(step); break; } case 'node_finished': { const step = traceByExecId.get(data.id); const patch: Partial = { status: (data.status as NodeStatus) ?? 'succeeded', inputs: data.inputs ?? step?.inputs, outputs: data.outputs, error: data.error ?? null, elapsedTime: typeof data.elapsed_time === 'number' ? data.elapsed_time : undefined, tokens: data.execution_metadata?.total_tokens, finishedAt: data.finished_at }; if (step) { Object.assign(step, patch); } else { trace.push({ execId: data.id, nodeId: data.node_id, nodeType: data.node_type, title: data.title ?? data.node_id, index: typeof data.index === 'number' ? data.index : trace.length + 1, ...patch } as NodeTraceStep); } break; } case 'message': { if (typeof evt.answer === 'string') answer += evt.answer; messageId = messageId || evt.message_id || evt.id; break; } case 'message_end': { messageId = messageId || evt.message_id || evt.id; break; } case 'workflow_finished': { finalStatus = data.status; finalOutputs = data.outputs; totalTokens = typeof data.total_tokens === 'number' ? data.total_tokens : totalTokens; elapsedTime = typeof data.elapsed_time === 'number' ? data.elapsed_time : elapsedTime; if (data.error) streamError = data.error; break; } case 'error': { streamError = evt.message || 'Stream error'; finalStatus = finalStatus || 'failed'; break; } default: break; } }; try { while (true) { if (signal.aborted) { try { await reader.cancel(); } catch {/* noop */} throw Object.assign(new Error('aborted'), { name: 'AbortError' }); } const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep: number; // SSE frames end with blank line; tolerate both \n\n and \r\n\r\n while ((sep = buffer.search(/\r?\n\r?\n/)) !== -1) { const rawFrame = buffer.slice(0, sep); buffer = buffer.slice(sep + (buffer[sep] === '\r' ? 4 : 2)); const dataLines: string[] = []; for (const line of rawFrame.split(/\r?\n/)) { if (!line || line.startsWith(':')) continue; // comment / keep-alive if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()); } } if (!dataLines.length) continue; const payload = dataLines.join('\n'); if (payload === '[DONE]') continue; try { const evt = JSON.parse(payload); handleEvent(evt); onUpdate(snapshot()); } catch { // ignore malformed frames rather than blowing up the whole run } } } } catch (err: any) { if (err?.name === 'AbortError') throw err; streamError = streamError || err?.message || 'Stream read error'; } return snapshot(); } export default function App() { const [step, setStep] = useState(1); const [inputs, setInputs] = useState([]); const [testCases, setTestCases] = useState([]); const [isExecuting, setIsExecuting] = useState(false); const [isStopping, setIsStopping] = useState(false); const [captureTrace, setCaptureTrace] = useState(false); const [traceDialogResult, setTraceDialogResult] = useState(null); const [difyConfig, setDifyConfig] = useState({ apiKey: '', apiUrl: 'https://api.dify.ai/v1', appMode: 'chat' }); const canCaptureTrace = difyConfig.appMode === 'workflow' || difyConfig.appMode === 'chat'; const excelInputRef = useRef(null); const abortControllerRef = useRef(null); // Step 1: Fetch Parameters from API const fetchParameters = async () => { if (!difyConfig.apiKey || !difyConfig.apiUrl) { toast.error('API Key 和 API URL 必填'); return; } setIsExecuting(true); const baseUrl = difyConfig.apiUrl.replace(/\/$/, ''); try { // authoritative app mode detection using /info const infoResponse = await fetch(`${baseUrl}/info`, { headers: { 'Authorization': `Bearer ${difyConfig.apiKey}` } }); let detectedMode: AppMode = 'workflow'; if (infoResponse.ok) { const infoData = await infoResponse.json(); // authoritative check: advanced-chat or workflow if (infoData.mode === 'advanced-chat') { detectedMode = 'chat'; } else { detectedMode = 'workflow'; } } if (detectedMode !== difyConfig.appMode) { toast.error(`模式冲突:检测到此应用为 ${detectedMode === 'chat' ? 'Advanced-Chat/ChatFlow' : 'Workflow'} 类型,而您选择了 ${difyConfig.appMode.toUpperCase()}。请更正后重试。`); return; } const response = await fetch(`${baseUrl}/parameters`, { headers: { 'Authorization': `Bearer ${difyConfig.apiKey}` } }); if (response.status === 404) { setInputs([]); setTestCases([{ id: crypto.randomUUID(), inputs: {}, times: 1, status: 'idle', progress: 0, results: [] }]); setStep(2); toast.success(`已成功连接到 ${detectedMode === 'chat' ? 'Advanced-Chat' : 'Workflow'} 应用`); return; } const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to fetch parameters'); const extractedInputs: DifyInput[] = data.user_input_form?.map((item: any) => { const type = Object.keys(item)[0]; const config = item[type]; return { name: config.variable, type: type, label: config.label || config.variable }; }) || []; if (extractedInputs.length === 0) { toast.info('未发现用户输入字段,但配置已加载。'); } setInputs(extractedInputs); // Step 2 starts with an empty table as requested setTestCases([]); setStep(2); toast.success('API 连接成功,配置已加载'); } catch (error: any) { console.error('Fetch error:', error); toast.error(error.message || '连接失败'); } finally { setIsExecuting(false); } }; // Step 2: Manage Test Cases const downloadExcelTemplate = () => { try { if (inputs.length === 0 && difyConfig.appMode !== 'chat') { toast.error('未检测到输入变量'); return; } // Generate headers based on app mode and inputs const headers: string[] = []; if (difyConfig.appMode === 'chat') { headers.push('query'); } inputs.forEach(input => { headers.push(input.label || input.name); }); headers.push('times'); // Create a single empty row as example const exampleRow = headers.map(h => { if (h === 'times') return 1; return ''; }); const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "测试模板"); // Filename includes app mode for clarity const filename = `dify_${difyConfig.appMode}_template.xlsx`; XLSX.writeFile(wb, filename); toast.success('模板已生成并下载'); } catch (error) { console.error('Template Download Error:', error); toast.error('模板生成失败'); } }; const handleExcelImport = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = new Uint8Array(event.target?.result as ArrayBuffer); const workbook = XLSX.read(data, { type: 'array' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet) as any[]; if (jsonData.length === 0) { toast.error('Excel 文件为空'); return; } const newTestCases: TestCase[] = jsonData.map(row => { const testCaseInputs: Record = {}; inputs.forEach(input => { // Match column by label or name const value = row[input.label] ?? row[input.name] ?? ''; testCaseInputs[input.name] = value; }); return { id: crypto.randomUUID(), inputs: testCaseInputs, query: difyConfig.appMode === 'chat' ? (row['query'] || row['Query'] || '') : undefined, times: parseInt(row['times'] || row['Times'] || '1') || 1, status: 'idle', progress: 0, results: [] }; }); setTestCases(prev => [...prev, ...newTestCases]); toast.success(`已从 Excel 导入 ${newTestCases.length} 个测试用例`); // Reset input value to allow re-upload of same file if (excelInputRef.current) excelInputRef.current.value = ''; } catch (error) { console.error('Excel import error:', error); toast.error('Excel 文件解析失败'); } }; reader.readAsArrayBuffer(file); }; const addTestCase = () => { const newCase: TestCase = { id: crypto.randomUUID(), inputs: {}, query: difyConfig.appMode === 'chat' ? '' : undefined, times: 1, status: 'idle', progress: 0, results: [] }; inputs.forEach(input => { newCase.inputs[input.name] = ''; }); setTestCases([...testCases, newCase]); }; const addVariable = (name: string) => { if (!name || inputs.some(i => i.name === name)) return; const newVariable: DifyInput = { name, type: 'text', label: name }; setInputs([...inputs, newVariable]); // Also update existing test cases to have this input key setTestCases(testCases.map(c => ({ ...c, inputs: { ...c.inputs, [name]: '' } }))); }; const removeVariable = (name: string) => { setInputs(inputs.filter(i => i.name !== name)); setTestCases(testCases.map(c => { const newInputs = { ...c.inputs }; delete newInputs[name]; return { ...c, inputs: newInputs }; })); }; const removeTestCase = (id: string) => { setTestCases(testCases.filter(c => c.id !== id)); }; const updateTestCase = (id: string, field: string, value: any, isInput = true) => { setTestCases(testCases.map(c => { if (c.id === id) { if (field === 'query') { return { ...c, query: value }; } if (isInput) { return { ...c, inputs: { ...c.inputs, [field]: value } }; } else { return { ...c, [field]: value }; } } return c; })); }; const stopTests = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); setIsStopping(true); toast.info('正在停止任务...'); } }; const runTests = async () => { if (!difyConfig.apiKey) { toast.error('必须输入 Dify API Key'); return; } if (testCases.length === 0) { toast.error('请先添加至少一个测试用例'); return; } setIsExecuting(true); setIsStopping(false); abortControllerRef.current = new AbortController(); const updatedCases = [...testCases].map(c => ({ ...c, status: 'running' as const, progress: 0, results: [] as TestResult[] })); setTestCases(updatedCases); const baseUrl = difyConfig.apiUrl.replace(/\/$/, ''); let endpoint = ''; if (difyConfig.appMode === 'chat') { endpoint = `${baseUrl}/chat-messages`; } else if (difyConfig.appMode === 'completion') { endpoint = `${baseUrl}/completion-messages`; } else { endpoint = `${baseUrl}/workflows/run`; } // Only workflow + advanced-chat emit node-level events. Completion apps don't. const useStreaming = captureTrace && canCaptureTrace; let stoppedManually = false; for (let i = 0; i < updatedCases.length; i++) { if (abortControllerRef.current.signal.aborted) { stoppedManually = true; break; } const testCase = updatedCases[i]; const times = testCase.times || 1; const results: TestResult[] = []; for (let t = 0; t < times; t++) { if (abortControllerRef.current.signal.aborted) { stoppedManually = true; break; } try { const body: any = { inputs: testCase.inputs, response_mode: useStreaming ? 'streaming' : 'blocking', user: 'batch-test-user' }; if (difyConfig.appMode === 'chat') { body.query = testCase.query || ''; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${difyConfig.apiKey}` }, body: JSON.stringify(body), signal: abortControllerRef.current.signal }); if (useStreaming) { if (!response.ok || !response.body) { let errMsg = `HTTP ${response.status}`; try { const errData = await response.json(); errMsg = errData.message || errMsg; } catch {/* non-json */} throw new Error(errMsg); } const streamed = await consumeDifyStream( response.body, difyConfig.appMode, abortControllerRef.current.signal, (partial) => { // live update: publish in-flight trace so the UI ticks per node const partialResults: TestResult[] = [...results, partial]; setTestCases(prev => prev.map(c => c.id === testCase.id ? { ...c, progress: Math.round(((t + (partial.trace?.length ? 0.5 : 0.1)) / times) * 100), results: partialResults } : c)); } ); results.push(streamed); } else { const data = await response.json(); if (!response.ok) throw new Error(data.message || 'API Error'); results.push(data as TestResult); } } catch (error: any) { if (error.name === 'AbortError') { stoppedManually = true; break; } console.error(`Error in test case ${i + 1}, iteration ${t + 1}:`, error); results.push({ error: error.message }); } const currentProgress = Math.round(((t + 1) / times) * 100); setTestCases(prev => prev.map(c => c.id === testCase.id ? { ...c, progress: currentProgress, results } : c)); } if (stoppedManually) break; setTestCases(prev => prev.map(c => c.id === testCase.id ? { ...c, status: 'completed', progress: 100 } : c)); } setIsExecuting(false); setIsStopping(false); abortControllerRef.current = null; if (!stoppedManually) { setStep(3); toast.success('批量测试已完成'); } else { setTestCases(prev => prev.map(c => c.status === 'running' ? { ...c, status: 'idle' } : c)); toast.info('任务已终止'); } }; // Step 3: Export to Excel const exportToExcel = () => { const includeTrace = testCases.some(c => c.results.some(r => (r.trace?.length ?? 0) > 0)); // Discover all dynamic column keys up front so header ordering is stable // regardless of whether the first row happens to have them populated. const inputKeys = new Set(); const outputKeys = new Set(); let sawScalarOutput = false; let sawError = false; for (const c of testCases) { Object.keys(c.inputs).forEach(k => inputKeys.add(k)); for (const r of c.results) { if (r.error) sawError = true; if (!r.error) { const out = r.data?.outputs ?? r.answer; if (out && typeof out === 'object') { Object.keys(out).forEach(k => outputKeys.add(k)); } else if (out !== undefined) { sawScalarOutput = true; } } } } const dataForExport = testCases.flatMap((testCase, index) => { return testCase.results.map((result, rIndex) => { const row: Record = { 'Test Case': index + 1, 'Iteration': rIndex + 1, }; if (difyConfig.appMode === 'chat') { row['User Query'] = testCase.query || ''; } for (const k of inputKeys) { row[k] = testCase.inputs[k] ?? ''; } if (includeTrace) { row['Steps'] = result.trace?.length ?? ''; row['Total Tokens'] = result.totalTokens ?? ''; row['Elapsed (s)'] = result.elapsedTime != null ? Number(result.elapsedTime.toFixed(2)) : ''; } if (result.error) { row['Error'] = result.error; for (const k of outputKeys) row[`Output_${k}`] = ''; if (sawScalarOutput) row['Output'] = ''; row['Task ID'] = result.task_id || result.id || ''; } else { const outputs = result.data?.outputs ?? result.answer; if (outputs && typeof outputs === 'object') { for (const k of outputKeys) { const v = (outputs as any)[k]; row[`Output_${k}`] = v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : v; } if (sawScalarOutput) row['Output'] = ''; } else { for (const k of outputKeys) row[`Output_${k}`] = ''; if (sawScalarOutput) row['Output'] = outputs ?? ''; } if (sawError) row['Error'] = ''; row['Task ID'] = result.task_id || result.id || ''; } if (includeTrace) { row['Trace'] = result.trace?.length ? formatTraceForCell(result.trace, { steps: result.trace.length, tokens: result.totalTokens, elapsed: result.elapsedTime, status: result.finalStatus }) : ''; } return row; }); }); // Explicit header locks column order even if earliest rows have empty traces. const header: string[] = [ 'Test Case', 'Iteration', ...(difyConfig.appMode === 'chat' ? ['User Query'] : []), ...inputKeys, ...(includeTrace ? ['Steps', 'Total Tokens', 'Elapsed (s)'] : []), ...Array.from(outputKeys).map(k => `Output_${k}`), ...(sawScalarOutput ? ['Output'] : []), ...(sawError ? ['Error'] : []), 'Task ID', ...(includeTrace ? ['Trace'] : []) ]; const worksheet = XLSX.utils.json_to_sheet(dataForExport, { header }); // Widen a few important columns so the sheet is immediately readable. const colWidths: { wch: number }[] = header.map(h => { if (h === 'Trace') return { wch: 80 }; if (h.startsWith('Output') || h === 'User Query') return { wch: 40 }; if (h === 'Error') return { wch: 40 }; if (h === 'Task ID') return { wch: 36 }; return { wch: 14 }; }); worksheet['!cols'] = colWidths; // Turn on wrap-text for the Trace column so the multi-line content renders. // NOTE: the default `xlsx` build doesn't persist cell styles on write, so // this line is a no-op there; users can toggle wrap once in Excel. If // `xlsx-js-style` is swapped in, the style survives the write. const traceColIdx = header.indexOf('Trace'); if (traceColIdx >= 0 && worksheet['!ref']) { const range = XLSX.utils.decode_range(worksheet['!ref']); for (let R = range.s.r + 1; R <= range.e.r; R++) { const ref = XLSX.utils.encode_cell({ r: R, c: traceColIdx }); const cell = (worksheet as any)[ref]; if (cell) { cell.s = { ...(cell.s || {}), alignment: { wrapText: true, vertical: 'top' } }; } } } const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 'Results'); XLSX.writeFile(workbook, `dify_batch_test_${new Date().toISOString().split('T')[0]}.xlsx`); }; const getFlatResults = () => { return testCases.flatMap((testCase, index) => { return testCase.results.map((result, rIndex) => { const outputs = result.error ? result.error : (result.data?.outputs || result.answer || JSON.stringify(result)); const outputString = typeof outputs === 'object' ? JSON.stringify(outputs, null, 2) : String(outputs); return { caseIndex: index + 1, iteration: rIndex + 1, inputs: testCase.inputs, query: testCase.query, isError: !!result.error, output: outputString, taskId: result.task_id || result.id || 'N/A', result }; }); }); }; const anyTrace = testCases.some(c => c.results.some(r => (r.trace?.length ?? 0) > 0)); const ResultsTable = ({ flatResults, inputs, appMode, showTrace }: { flatResults: any[], inputs: DifyInput[], appMode: AppMode, showTrace: boolean }) => ( 序号 迭代 {appMode === 'chat' && ( 用户提问 )} {inputs.map(input => ( {input.label} ))} 状态 {showTrace && ( 追踪 )} 响应内容 {flatResults.map((row, idx) => { const trace: NodeTraceStep[] | undefined = row.result?.trace; const hasTrace = (trace?.length ?? 0) > 0; const traceTooltipParts: string[] = []; if (row.result?.elapsedTime != null) traceTooltipParts.push(`耗时 ${row.result.elapsedTime.toFixed(2)}s`); if (row.result?.totalTokens != null) traceTooltipParts.push(`Tokens ${row.result.totalTokens}`); return ( {String(row.caseIndex).padStart(2, '0')} {String(row.iteration).padStart(2, '0')} {appMode === 'chat' && ( {row.query || '-'} )} {inputs.map(input => ( {String(row.inputs[input.name] || '-')} ))} {row.isError ? (
失败
) : (
成功
)} {showTrace && ( {hasTrace ? ( ) : ( )} )}
{row.output}
); })}
); return (
{/* Header / Stepper */}
BT

Dify Batch Tester

{/* Central Progress Stepper */}
{[ { id: 1, label: 'API 连接' }, { id: 2, label: '配置与执行' }, { id: 3, label: '下载结果' } ].map((s, idx) => { const isPast = step > s.id; const isCurrent = step === s.id; const canClick = !isExecuting && !isStopping && (isPast || isCurrent); return (
{idx < 2 && (
s.id ? 'bg-[#10b981]' : 'bg-[#e2e8f0]'}`} />
)}
); })}
0 ? 'bg-[#10b981]' : 'bg-amber-400'}`} />
{inputs.length > 0 ? ( {difyConfig.appMode.toUpperCase()} {inputs.length} 个字段 ) : '等待连接'}
{step === 1 && (

Dify 批量自动化
测试终端

同步您的 Dify 应用配置,通过自动化批量运行实时评估提示词(Prompt)表现。支持大规模数据批处理与结果导出。

助力开发者精细化调优 LLM 任务,确保应用在海量数据输入下的稳定性与可靠性。

{/* Mode Selection Toggle */}
{(['chat', 'workflow'] as AppMode[]).map((mode) => ( ))}
{/* Input Rows */}
setDifyConfig({ ...difyConfig, apiUrl: e.target.value })} placeholder="https://api.dify.ai/v1" className="h-12 pl-10 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-slate-200 transition-all font-medium placeholder:text-slate-300" />
setDifyConfig({ ...difyConfig, apiKey: e.target.value })} placeholder="app-xxxxxxxxxxxxxxxxxxxxxxxx" className="h-12 pl-10 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-slate-200 transition-all font-medium placeholder:text-slate-300" />
模式将通过 API 响应自动验证
)} {step === 2 && (

配置测试用例

为每个测试用例填写输入字段并指定重复次数。

{canCaptureTrace && ( )} {isExecuting ? ( ) : ( )}

用例矩阵

{difyConfig.appMode} {testCases.length} CASE(S)
{testCases.length > 0 && ( 序号 {difyConfig.appMode === 'chat' && ( User Query )} {inputs.map(input => (
{input.label}
))} 次数 执行进度
)} {testCases.length > 0 ? ( testCases.map((testCase, index) => ( {String(index + 1).padStart(2, '0')} {difyConfig.appMode === 'chat' && ( updateTestCase(testCase.id, 'query', e.target.value)} className="border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 transition-all h-9 rounded-lg font-medium placeholder:text-slate-300 text-sm" /> )} {inputs.map(input => ( updateTestCase(testCase.id, input.name, e.target.value)} className="border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 transition-all h-9 rounded-lg font-medium placeholder:text-slate-300 text-sm" /> ))} updateTestCase(testCase.id, 'times', parseInt(e.target.value) || 1, false)} className="w-20 text-center border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 h-9 rounded-lg font-mono font-bold" /> {testCase.status === 'idle' ? ( Pending ) : (() => { const lastResult = testCase.results[testCase.results.length - 1]; // Only surface the node label while a step is actively running, // so it clears between iterations instead of showing a stale one. const runningStep = testCase.status === 'running' ? lastResult?.trace?.find(s => s.status === 'running') : undefined; const done = testCase.results.length; const total = testCase.times; return (
{testCase.status === 'running' ? `In Progress (${done}/${total})` : `Complete (${total}/${total})`} {runningStep?.title && ( {runningStep.title} )}
); })()}
)) ) : (

暂无测试用例

点击下方按钮手动添加或从 Excel 导入

)}
{testCases.length > 5 && (
)} )} {step === 3 && (

执行完成

所有 {testCases.length} 个测试用例已成功执行。结果已在下方展示,您可以进行预览或直接下载导出。

总测试用例数 {testCases.length} 成功执行次数 {testCases.reduce((acc, c) => acc + c.results.filter(r => !r.error).length, 0)} 失败次数 {testCases.reduce((acc, c) => acc + c.results.filter(r => r.error).length, 0)}

结果透视

)} {/* Removed Footer Decoration for Sleek Theme */}
{/* Footer Decoration REMOVED to match Sleek Interface minimal look */} !open && setTraceDialogResult(null)}> 节点执行追踪 {traceDialogResult?.workflowRunId && ( Run ID: {traceDialogResult.workflowRunId} )}
{traceDialogResult?.trace?.length ?? 0} STEPS {traceDialogResult?.totalTokens != null && ( {traceDialogResult.totalTokens} TOKENS )} {traceDialogResult?.elapsedTime != null && ( {traceDialogResult.elapsedTime.toFixed(2)}s )} {traceDialogResult?.finalStatus && ( {String(traceDialogResult.finalStatus).toUpperCase()} )}
{traceDialogResult?.trace?.length ? ( traceDialogResult.trace.map((step, i) => (
#{String(step.index ?? i + 1).padStart(2, '0')} {step.nodeType} {step.title} {step.status === 'running' && } {step.status}
{step.elapsedTime != null && ( {step.elapsedTime.toFixed(2)}s )} {step.tokens != null && ( {step.tokens} tokens )} {step.nodeId && ( id: {step.nodeId} )}
{step.error && (
Error
{step.error}
)} {step.inputs !== undefined && (
Inputs
{safeStringify(step.inputs)}
)} {step.outputs !== undefined && (
Outputs
{safeStringify(step.outputs)}
)}
)) ) : (
无追踪数据
)}
); } function safeStringify(v: unknown): string { try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2); } catch { return String(v); } } // Excel cap is 32,767 chars per cell; leave headroom for safety. const EXCEL_CELL_LIMIT = 32000; const TRACE_INLINE_JSON_LIMIT = 300; function briefInlineJson(v: unknown): string { if (v == null) return ''; let s: string; try { s = typeof v === 'string' ? v : JSON.stringify(v); } catch { s = String(v); } if (s.length > TRACE_INLINE_JSON_LIMIT) { return s.slice(0, TRACE_INLINE_JSON_LIMIT) + `…(+${s.length - TRACE_INLINE_JSON_LIMIT} chars)`; } return s; } function formatTraceForCell( trace: NodeTraceStep[], total: { steps: number; tokens?: number; elapsed?: number; status?: NodeStatus | string } ): string { const lines: string[] = []; for (const s of trace) { const headerParts = [ `#${s.index ?? '?'} [${s.nodeType}] ${s.title} · ${s.status}` ]; if (s.elapsedTime != null) headerParts.push(`${s.elapsedTime.toFixed(2)}s`); if (s.tokens) headerParts.push(`${s.tokens} tok`); lines.push(headerParts.join(' · ')); if (s.status === 'succeeded') { if (s.inputs !== undefined) lines.push(` in : ${briefInlineJson(s.inputs)}`); if (s.outputs !== undefined) lines.push(` out: ${briefInlineJson(s.outputs)}`); } else if (s.error) { lines.push(` err: ${briefInlineJson(s.error)}`); } lines.push(''); } const totalParts: string[] = [`${total.steps} steps`]; if (total.tokens != null) totalParts.push(`${total.tokens} tok`); if (total.elapsed != null) totalParts.push(`${total.elapsed.toFixed(2)}s`); if (total.status) totalParts.push(String(total.status)); lines.push(`TOTAL ${totalParts.join(' · ')}`); let out = lines.join('\n'); if (out.length > EXCEL_CELL_LIMIT) { out = out.slice(0, EXCEL_CELL_LIMIT) + `\n…(truncated, +${out.length - EXCEL_CELL_LIMIT} chars)`; } return out; }