Files
dify-batch-tester/src/App.tsx
2026-04-18 23:03:13 +08:00

1569 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Uint8Array>,
appMode: AppMode,
signal: AbortSignal,
onUpdate: (partial: TestResult) => void
): Promise<TestResult> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const trace: NodeTraceStep[] = [];
const traceByExecId = new Map<string, NodeTraceStep>();
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<NodeTraceStep> = {
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<DifyInput[]>([]);
const [testCases, setTestCases] = useState<TestCase[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [captureTrace, setCaptureTrace] = useState(false);
const [traceDialogResult, setTraceDialogResult] = useState<TestResult | null>(null);
const [difyConfig, setDifyConfig] = useState<DifyConfig>({
apiKey: '',
apiUrl: 'https://api.dify.ai/v1',
appMode: 'chat'
});
const canCaptureTrace = difyConfig.appMode === 'workflow' || difyConfig.appMode === 'chat';
const excelInputRef = useRef<HTMLInputElement>(null);
const abortControllerRef = useRef<AbortController | null>(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<HTMLInputElement>) => {
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<string, any> = {};
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<string>();
const outputKeys = new Set<string>();
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<string, any> = {
'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 }) => (
<Table>
<TableHeader className="bg-[#f8fafc] sticky top-0 z-20">
<TableRow className="hover:bg-transparent border-b border-[#e2e8f0]">
<TableHead className="w-14 h-12 px-3 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
<TableHead className="w-14 h-12 px-3 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
{appMode === 'chat' && (
<TableHead className="min-w-[140px] max-w-[220px] h-12 px-4 font-bold text-[10px] text-[#0f172a] uppercase tracking-wider"></TableHead>
)}
{inputs.map(input => (
<TableHead key={input.name} className="min-w-[90px] max-w-[160px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider truncate">{input.label}</TableHead>
))}
<TableHead className="w-24 h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
{showTrace && (
<TableHead className="w-[96px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
)}
<TableHead className="w-[320px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={idx} className="hover:bg-slate-50/50 border-b border-[#f1f5f9] transition-colors">
<TableCell className="px-3 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.caseIndex).padStart(2, '0')}</TableCell>
<TableCell className="px-3 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.iteration).padStart(2, '0')}</TableCell>
{appMode === 'chat' && (
<TableCell className="px-4 py-4 text-xs font-medium text-[#0f172a] max-w-[220px] truncate" title={row.query || ''}>
{row.query || '-'}
</TableCell>
)}
{inputs.map(input => (
<TableCell key={input.name} className="px-4 py-4 text-xs text-[#64748b] max-w-[160px] truncate" title={String(row.inputs[input.name] ?? '')}>
{String(row.inputs[input.name] || '-')}
</TableCell>
))}
<TableCell className="px-4 py-4">
{row.isError ? (
<div className="flex items-center gap-1.5 text-red-500 font-bold text-[9px] uppercase tracking-tight">
<div className="h-1 w-1 rounded-full bg-red-500" />
</div>
) : (
<div className="flex items-center gap-1.5 text-[#10b981] font-bold text-[9px] uppercase tracking-tight">
<div className="h-1 w-1 rounded-full bg-[#10b981]" />
</div>
)}
</TableCell>
{showTrace && (
<TableCell className="px-4 py-4 align-middle">
{hasTrace ? (
<button
type="button"
onClick={() => setTraceDialogResult(row.result)}
title={traceTooltipParts.length ? `${trace!.length} 步 · ${traceTooltipParts.join(' · ')}` : `${trace!.length}`}
className="group inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left transition-all hover:border-slate-300 hover:shadow-sm active:scale-[0.98]"
>
<Activity className="h-3 w-3 text-[#0f172a]" />
<span className="text-[10px] font-bold text-[#0f172a]">{trace!.length} </span>
<ChevronRight className="h-3 w-3 text-slate-300 group-hover:translate-x-0.5 transition-transform" />
</button>
) : (
<span className="text-[10px] text-slate-300"></span>
)}
</TableCell>
)}
<TableCell className="px-4 py-4 align-top">
<div className="w-[300px] max-w-[300px] max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed bg-slate-50 p-3 rounded-xl border border-slate-100 text-[#334155] shadow-[inset_0_1px_2px_rgba(0,0,0,0.02)]">
{row.output}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
return (
<div className="min-h-screen bg-[#f8fafc] font-sans text-[#020817] antialiased">
<Toaster position="top-center" />
{/* Header / Stepper */}
<header className="sticky top-0 z-50 border-b border-[#e2e8f0] bg-white/80 backdrop-blur-md py-3.5">
<div className="relative mx-auto flex w-full max-w-7xl items-center justify-between px-6 sm:px-8 lg:px-10">
<div className="flex items-center gap-2.5">
<div className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-[#0f172a] shadow-lg shadow-slate-200">
<span className="text-[10px] font-bold text-white tracking-tighter">BT</span>
</div>
<div className="flex flex-col">
<h1 className="text-sm font-bold tracking-tight text-[#020817] leading-none">Dify Batch Tester</h1>
</div>
</div>
{/* Central Progress Stepper */}
<div className="absolute left-1/2 hidden -translate-x-1/2 items-center justify-center gap-1.5 md:flex">
{[
{ 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 (
<div key={s.id} className="flex items-center">
<button
onClick={() => canClick && setStep(s.id)}
disabled={!canClick}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-200 ${
canClick ? 'hover:bg-slate-50 cursor-pointer active:scale-95' : 'cursor-not-allowed'
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-full border text-[9px] font-bold transition-all duration-500 ${
isCurrent ? 'border-[#0f172a] bg-[#0f172a] text-white ring-4 ring-slate-100 shadow-sm' :
isPast ? 'border-[#10b981] bg-[#10b981] text-white' : 'border-[#e2e8f0] bg-white text-[#94a3b8]'
}`}>
{isPast ? '✓' : s.id}
</div>
<span className={`hidden text-[11px] font-semibold whitespace-nowrap tracking-tight transition-colors duration-300 lg:inline ${
isCurrent ? 'text-[#0f172a]' : 'text-[#64748b]'
}`}>
{s.label}
</span>
</button>
{idx < 2 && (
<div className="flex items-center px-2">
<div className={`h-[1px] w-4 transition-colors duration-500 ${step > s.id ? 'bg-[#10b981]' : 'bg-[#e2e8f0]'}`} />
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end">
<div className="flex items-center gap-2 bg-[#f8fafc] border border-[#e2e8f0] px-2.5 py-1 rounded-md shadow-[inset_0_1px_1px_rgba(0,0,0,0.01)]">
<div className={`h-1.5 w-1.5 rounded-full animate-pulse ${inputs.length > 0 ? 'bg-[#10b981]' : 'bg-amber-400'}`} />
<div className="font-mono text-[9px] font-bold tracking-tight text-[#475569] uppercase">
{inputs.length > 0 ? (
<span className="flex items-center gap-1.5">
<span className="text-[#94a3b8]">{difyConfig.appMode.toUpperCase()}</span>
<span className="h-2.5 w-[1px] bg-[#e2e8f0]" />
<span>{inputs.length} </span>
</span>
) : '等待连接'}
</div>
</div>
</div>
</div>
</header>
<main
className={`mx-auto flex w-full max-w-7xl flex-col px-6 py-10 sm:px-8 lg:px-10 ${
step === 1 ? 'min-h-[calc(100vh-65px)] justify-center' : 'gap-8'
}`}
>
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
className="flex w-full flex-col items-center justify-center gap-12 lg:flex-row lg:gap-20"
>
<div className="text-left max-w-[440px] lg:max-w-[400px]">
<div className="mb-6 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#0f172a] text-white shadow-lg shadow-slate-200">
<Play className="h-5 w-5 fill-current" />
</div>
<h2 className="text-4xl font-bold tracking-tight text-[#0f172a] mb-6 leading-[1.2]">
Dify <br />
</h2>
<div className="space-y-4">
<p className="text-base text-slate-500 leading-relaxed font-medium">
Dify Prompt
</p>
<p className="text-sm text-slate-400 leading-relaxed">
LLM
</p>
</div>
</div>
<Card className="w-full max-w-[440px] border-[#e2e8f0] bg-white shadow-2xl shadow-slate-200/60 rounded-[40px] overflow-hidden border-t border-l border-white/50">
<div className="p-10 space-y-10">
{/* Mode Selection Toggle */}
<div className="flex flex-col items-center gap-5">
<div className="bg-[#f1f5f9] p-1 rounded-full flex gap-1 border border-[#e2e8f0]">
{(['chat', 'workflow'] as AppMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setDifyConfig({ ...difyConfig, appMode: mode })}
className={`px-6 py-2 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all duration-300 ${
difyConfig.appMode === mode
? 'bg-white text-[#0f172a] shadow-sm ring-1 ring-slate-200/50'
: 'text-[#64748b] hover:text-[#0f172a]'
}`}
>
{mode === 'chat' ? '高级对话 (Chat)' : '工作流 (Workflow)'}
</button>
))}
</div>
</div>
{/* Input Rows */}
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="step1-apiUrl" className="text-[10px] pl-1 font-bold text-[#94a3b8] uppercase tracking-[0.1em]">API URL</Label>
<div className="relative group">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#cbd5e1] group-focus-within:text-[#64748b] transition-colors">
<Globe className="h-4 w-4" />
</div>
<Input
id="step1-apiUrl"
value={difyConfig.apiUrl}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="step1-apiKey" className="text-[10px] pl-1 font-bold text-[#94a3b8] uppercase tracking-[0.1em]"> (API Key)</Label>
<div className="relative group">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#cbd5e1] group-focus-within:text-[#64748b] transition-colors">
<Key className="h-4 w-4" />
</div>
<Input
id="step1-apiKey"
type="password"
value={difyConfig.apiKey}
onChange={(e) => 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"
/>
</div>
</div>
</div>
<div className="space-y-4">
<Button
onClick={fetchParameters}
disabled={isExecuting}
className="w-full h-14 bg-[#0f172a] hover:bg-[#1e293b] text-white font-bold rounded-2xl gap-3 shadow-xl shadow-slate-200 transition-all active:scale-[0.98]"
>
{isExecuting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ChevronRight className="h-5 w-5" />}
{isExecuting ? '正在连接...' : '连接到 Dify 应用'}
</Button>
<div className="flex items-center justify-center gap-2 text-[10px] font-medium text-[#94a3b8]">
<ShieldCheck className="h-3 w-3" />
<span> API </span>
</div>
</div>
</div>
</Card>
</motion.div>
)}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-8"
>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-3xl font-bold tracking-tight text-[#0f172a]"></h2>
<p className="text-slate-500 mt-2 text-sm font-medium"></p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={() => {
setTestCases([{
id: crypto.randomUUID(),
inputs: Object.fromEntries(inputs.map(i => [i.name, ''])),
times: 1,
status: 'idle',
progress: 0,
results: []
}]);
toast.success('测试用例已清空');
}}
variant="ghost"
disabled={isExecuting}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-full h-10 px-4 transition-all"
>
</Button>
<div className="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm h-10">
<Button
onClick={addTestCase}
variant="ghost"
disabled={isExecuting}
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#0f172a]"
>
<Plus className="h-4 w-4" />
</Button>
<Button
onClick={() => excelInputRef.current?.click()}
variant="ghost"
disabled={isExecuting}
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#64748b] border-l border-slate-100"
>
<Download className="h-4 w-4 rotate-180" />
</Button>
<Button
onClick={downloadExcelTemplate}
variant="ghost"
disabled={isExecuting}
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#64748b] border-l border-slate-100"
>
<Download className="h-4 w-4" />
</Button>
</div>
<input
type="file"
ref={excelInputRef}
className="hidden"
accept=".xlsx,.xls,.csv"
onChange={handleExcelImport}
/>
{canCaptureTrace && (
<button
type="button"
role="switch"
aria-checked={captureTrace}
disabled={isExecuting}
onClick={() => setCaptureTrace(v => !v)}
title="开启后将使用流式响应捕获每个节点的执行详情(速度略慢)"
className={`flex items-center gap-2 h-10 rounded-full border px-4 text-xs font-bold transition-all ${
captureTrace
? 'bg-[#0f172a] text-white border-[#0f172a] shadow-lg shadow-slate-200'
: 'bg-white text-[#64748b] border-slate-200 hover:text-[#0f172a] hover:border-slate-300'
} ${isExecuting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer active:scale-[0.98]'}`}
>
<Activity className="h-4 w-4" />
<span></span>
<span
className={`ml-1 flex h-4 w-7 items-center rounded-full p-0.5 transition-colors ${
captureTrace ? 'bg-white/30' : 'bg-slate-200'
}`}
>
<span
className={`h-3 w-3 rounded-full bg-white shadow transition-transform ${
captureTrace ? 'translate-x-3' : 'translate-x-0'
}`}
/>
</span>
</button>
)}
{isExecuting ? (
<Button
onClick={stopTests}
disabled={isStopping}
variant="destructive"
className="gap-2 rounded-full h-10 px-6 shadow-lg shadow-red-100 transition-all font-bold"
>
{isStopping ? <Loader2 className="h-4 w-4 animate-spin" /> : <Slash className="h-4 w-4" />}
{isStopping ? '停止中' : '终止任务'}
</Button>
) : (
<Button
onClick={runTests}
disabled={isExecuting || testCases.length === 0}
className="gap-2 bg-[#0f172a] hover:bg-[#1e293b] rounded-full h-10 px-8 shadow-lg shadow-slate-200 transition-all font-bold"
>
<Play className="h-4 w-4" />
</Button>
)}
</div>
</div>
<Card className="border-[#e2e8f0] shadow-xl shadow-slate-200/40 overflow-hidden rounded-[24px] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e8f0] px-8 py-5 bg-white">
<h3 className="text-base font-bold text-[#0f172a]"></h3>
<div className="flex items-center gap-2 bg-[#f8fafc] border border-[#e2e8f0] px-3 py-1 rounded-full">
<div className="h-1.5 w-1.5 rounded-full bg-[#10b981]" />
<div className="font-mono text-[10px] font-bold tracking-tight text-[#475569] uppercase flex items-center gap-2">
<span>{difyConfig.appMode}</span>
<span className="h-2.5 w-[1px] bg-[#e2e8f0]" />
<span>{testCases.length} CASE(S)</span>
</div>
</div>
</div>
<div className="overflow-x-auto">
<Table>
{testCases.length > 0 && (
<TableHeader className="bg-[#f1f5f9]">
<TableRow className="hover:bg-transparent border-b border-[#e2e8f0]">
<TableHead className="w-16 h-10 px-6 font-medium text-[#64748b]"></TableHead>
{difyConfig.appMode === 'chat' && (
<TableHead className="min-w-[200px] h-10 px-6 font-semibold text-[#0f172a]">User Query</TableHead>
)}
{inputs.map(input => (
<TableHead key={input.name} className="h-10 px-6 font-medium text-[#64748b]">
<div className="flex items-center justify-between group">
<span>{input.label}</span>
<button
onClick={() => removeVariable(input.name)}
className="ml-2 opacity-0 group-hover:opacity-100 transition-opacity hover:text-red-500"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</TableHead>
))}
<TableHead className="w-24 h-10 px-6 text-center font-medium text-[#64748b]"></TableHead>
<TableHead className="w-48 h-10 px-6 font-medium text-[#64748b]"></TableHead>
<TableHead className="w-16 h-10 px-6 font-medium text-[#64748b]"></TableHead>
</TableRow>
</TableHeader>
)}
<TableBody>
{testCases.length > 0 ? (
testCases.map((testCase, index) => (
<TableRow key={testCase.id} className="hover:bg-[#f8fafc]/50 border-b border-[#e2e8f0]">
<TableCell className="px-6 font-mono text-sm text-[#64748b]">{String(index + 1).padStart(2, '0')}</TableCell>
{difyConfig.appMode === 'chat' && (
<TableCell className="px-6 py-4">
<Input
placeholder="输入用户消息..."
disabled={isExecuting}
value={testCase.query || ''}
onChange={(e) => 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"
/>
</TableCell>
)}
{inputs.map(input => (
<TableCell key={input.name} className="px-6 py-4">
<Input
placeholder={`输入 ${input.label}`}
disabled={isExecuting}
value={testCase.inputs[input.name] || ''}
onChange={(e) => 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"
/>
</TableCell>
))}
<TableCell className="px-6 py-4">
<Input
type="number"
min="1"
disabled={isExecuting}
value={testCase.times}
onChange={(e) => 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"
/>
</TableCell>
<TableCell className="px-6 py-4">
{testCase.status === 'idle' ? (
<Badge variant="secondary" className="bg-[#f8fafc] text-[#94a3b8] border border-[#e2e8f0] font-bold text-[9px] uppercase tracking-wider px-2 py-0.5 rounded-md">Pending</Badge>
) : (() => {
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 (
<div className="flex flex-col gap-1.5 min-w-24">
<Progress value={testCase.progress} className="h-1.5 bg-[#f1f5f9] [&>div]:bg-[#0f172a] rounded-full" />
<span className="text-[9px] font-bold text-[#64748b] uppercase tracking-tighter">
{testCase.status === 'running'
? `In Progress (${done}/${total})`
: `Complete (${total}/${total})`}
</span>
{runningStep?.title && (
<span className="flex items-center gap-1 text-[9px] text-slate-400 max-w-[200px]">
<Activity className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{runningStep.title}</span>
</span>
)}
</div>
);
})()}
</TableCell>
<TableCell className="px-6 py-4">
<Button
variant="ghost"
size="icon"
disabled={isExecuting}
onClick={() => removeTestCase(testCase.id)}
className="text-[#64748b] hover:text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={10} className="h-64 text-center">
<div className="flex flex-col items-center justify-center gap-4">
<div className="h-12 w-12 rounded-full bg-slate-50 flex items-center justify-center text-slate-300">
<Plus className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900"></p>
<p className="text-xs text-slate-500"> Excel </p>
</div>
<Button
onClick={addTestCase}
size="sm"
className="mt-2 bg-[#0f172a] hover:bg-[#1e293b] gap-2 rounded-full h-10 px-6 font-bold shadow-lg shadow-slate-100 transition-all"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
{testCases.length > 5 && (
<div className="flex justify-center">
<Button
onClick={addTestCase}
variant="ghost"
className="gap-2 text-slate-400 hover:text-[#0f172a] hover:bg-slate-50 rounded-full px-6 transition-all font-bold text-xs"
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
</motion.div>
)}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-10"
>
<div className="text-center">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-green-50 text-[#10b981]">
<CheckCircle2 className="h-10 w-10" />
</div>
<h2 className="text-4xl font-bold tracking-tight text-[#020817]"></h2>
<p className="text-[#64748b] text-lg mt-4 max-w-2xl mx-auto leading-relaxed">
{testCases.length}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full">
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
<CardHeader className="pb-3 px-8 pt-8">
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]"></CardDescription>
<CardTitle className="text-4xl font-bold text-[#020817] font-mono">{testCases.length}</CardTitle>
</CardHeader>
</Card>
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
<CardHeader className="pb-3 px-8 pt-8">
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]"></CardDescription>
<CardTitle className="text-4xl font-bold text-[#10b981] font-mono">
{testCases.reduce((acc, c) => acc + c.results.filter(r => !r.error).length, 0)}
</CardTitle>
</CardHeader>
</Card>
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
<CardHeader className="pb-3 px-8 pt-8">
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]"></CardDescription>
<CardTitle className="text-4xl font-bold text-red-500 font-mono">
{testCases.reduce((acc, c) => acc + c.results.filter(r => r.error).length, 0)}
</CardTitle>
</CardHeader>
</Card>
</div>
<Card className="border-[#e2e8f0] shadow-xl shadow-slate-200/40 overflow-hidden rounded-[24px] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e2e8f0] px-8 py-6 bg-white">
<h3 className="text-base font-bold text-[#0f172a]"></h3>
<div className="flex items-center gap-3">
<Button
onClick={exportToExcel}
size="sm"
className="h-10 px-6 gap-2 bg-[#0f172a] hover:bg-[#1e293b] rounded-full shadow-lg shadow-slate-200 text-xs font-bold transition-all active:scale-[0.98]"
>
<Download className="h-4 w-4" />
EXCEL
</Button>
</div>
</div>
<div className="max-h-[600px] overflow-auto">
<ResultsTable flatResults={getFlatResults()} inputs={inputs} appMode={difyConfig.appMode} showTrace={anyTrace} />
</div>
</Card>
<div className="flex flex-wrap justify-center gap-4 pt-4 pb-12">
<Button
variant="outline"
className="h-12 px-10 rounded-full border-slate-200 text-[#0f172a] font-bold hover:bg-slate-50 transition-all active:scale-95"
onClick={() => setStep(2)}
>
</Button>
<Button
variant="outline"
className="h-12 px-10 rounded-full border-slate-200 text-[#0f172a] font-bold hover:bg-slate-50 transition-all active:scale-95"
onClick={() => {
setStep(1);
setInputs([]);
setTestCases([]);
}}
>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Removed Footer Decoration for Sleek Theme */}
</main>
{/* Footer Decoration REMOVED to match Sleek Interface minimal look */}
<Dialog open={!!traceDialogResult} onOpenChange={(open) => !open && setTraceDialogResult(null)}>
<DialogContent className="sm:max-w-4xl max-w-[calc(100%-2rem)] w-full max-h-[85vh] overflow-hidden flex flex-col gap-0 p-0 rounded-[24px] border-[#e2e8f0]">
<DialogHeader className="flex flex-col gap-2 px-8 py-5 border-b border-[#e2e8f0]">
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-[#0f172a]">
<Activity className="h-5 w-5" />
</DialogTitle>
{traceDialogResult?.workflowRunId && (
<DialogDescription className="font-mono text-xs text-slate-500">
Run ID: {traceDialogResult.workflowRunId}
</DialogDescription>
)}
<div className="mt-1 flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1.5 bg-slate-100 text-[#0f172a] font-mono text-[10px]">
<Hash className="h-3 w-3" />
{traceDialogResult?.trace?.length ?? 0} STEPS
</Badge>
{traceDialogResult?.totalTokens != null && (
<Badge variant="secondary" className="gap-1.5 bg-slate-100 text-[#0f172a] font-mono text-[10px]">
<Coins className="h-3 w-3" />
{traceDialogResult.totalTokens} TOKENS
</Badge>
)}
{traceDialogResult?.elapsedTime != null && (
<Badge variant="secondary" className="gap-1.5 bg-slate-100 text-[#0f172a] font-mono text-[10px]">
<Clock className="h-3 w-3" />
{traceDialogResult.elapsedTime.toFixed(2)}s
</Badge>
)}
{traceDialogResult?.finalStatus && (
<Badge
variant="secondary"
className={`gap-1.5 font-mono text-[10px] ${
traceDialogResult.finalStatus === 'succeeded'
? 'bg-emerald-50 text-emerald-700 border border-emerald-100'
: 'bg-red-50 text-red-700 border border-red-100'
}`}
>
{String(traceDialogResult.finalStatus).toUpperCase()}
</Badge>
)}
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-8 py-5 space-y-3 bg-[#f8fafc]">
{traceDialogResult?.trace?.length ? (
traceDialogResult.trace.map((step, i) => (
<div key={step.execId || i} className="rounded-2xl border border-[#e2e8f0] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-bold text-[#94a3b8]">#{String(step.index ?? i + 1).padStart(2, '0')}</span>
<Badge variant="secondary" className="bg-slate-100 text-[#0f172a] font-mono text-[9px] uppercase tracking-wider">
{step.nodeType}
</Badge>
<span className="text-sm font-semibold text-[#0f172a] truncate max-w-[260px]">{step.title}</span>
<span className={`ml-auto flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
step.status === 'succeeded' ? 'bg-emerald-50 text-emerald-700'
: step.status === 'running' ? 'bg-amber-50 text-amber-700'
: step.status === 'failed' || step.status === 'exception' ? 'bg-red-50 text-red-700'
: 'bg-slate-100 text-slate-600'
}`}>
{step.status === 'running' && <Loader2 className="h-3 w-3 animate-spin" />}
{step.status}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-3 font-mono text-[10px] text-[#64748b]">
{step.elapsedTime != null && (
<span className="flex items-center gap-1"><Clock className="h-3 w-3" />{step.elapsedTime.toFixed(2)}s</span>
)}
{step.tokens != null && (
<span className="flex items-center gap-1"><Coins className="h-3 w-3" />{step.tokens} tokens</span>
)}
{step.nodeId && (
<span className="text-slate-400">id: {step.nodeId}</span>
)}
</div>
{step.error && (
<div className="mt-3 rounded-lg border border-red-100 bg-red-50 p-3 text-[11px] font-mono text-red-700 whitespace-pre-wrap break-all">
<div className="mb-1 flex items-center gap-1 font-bold text-[9px] uppercase tracking-wider"><AlertTriangle className="h-3 w-3" /> Error</div>
{step.error}
</div>
)}
{step.inputs !== undefined && (
<details className="mt-3 group">
<summary className="cursor-pointer text-[10px] font-bold uppercase tracking-wider text-[#94a3b8] hover:text-[#0f172a]">Inputs</summary>
<pre className="mt-2 max-h-48 overflow-auto rounded-lg bg-slate-50 border border-slate-100 p-3 font-mono text-[10px] leading-relaxed text-[#334155] whitespace-pre-wrap break-all">{safeStringify(step.inputs)}</pre>
</details>
)}
{step.outputs !== undefined && (
<details className="mt-2 group" open={i === (traceDialogResult?.trace?.length ?? 0) - 1}>
<summary className="cursor-pointer text-[10px] font-bold uppercase tracking-wider text-[#94a3b8] hover:text-[#0f172a]">Outputs</summary>
<pre className="mt-2 max-h-48 overflow-auto rounded-lg bg-slate-50 border border-slate-100 p-3 font-mono text-[10px] leading-relaxed text-[#334155] whitespace-pre-wrap break-all">{safeStringify(step.outputs)}</pre>
</details>
)}
</div>
))
) : (
<div className="flex h-40 items-center justify-center text-sm text-slate-400">
</div>
)}
</div>
<div className="flex justify-end gap-2 px-8 py-4 border-t border-[#e2e8f0] bg-white">
<Button
variant="outline"
onClick={() => setTraceDialogResult(null)}
className="rounded-full h-9 px-6 font-bold"
>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
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;
}