Add Dify SSE streaming and node execution trace UI

Made-with: Cursor
This commit is contained in:
Xin Wang
2026-04-18 14:04:05 +08:00
parent 721b186051
commit 0908e5f1d1
2 changed files with 618 additions and 38 deletions

View File

@@ -14,7 +14,12 @@ import {
ShieldCheck,
Slash,
Eye,
Key
Key,
Activity,
Clock,
Hash,
Coins,
AlertTriangle
} from 'lucide-react';
import yaml from 'js-yaml';
import * as XLSX from 'xlsx';
@@ -29,9 +34,167 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { DifyInput, TestCase, DifyConfig, AppMode } from './types';
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);
@@ -39,12 +202,16 @@ export default function App() {
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);
@@ -303,7 +470,7 @@ export default function App() {
setIsStopping(false);
abortControllerRef.current = new AbortController();
const updatedCases = [...testCases].map(c => ({ ...c, status: 'running' as const, progress: 0, results: [] }));
const updatedCases = [...testCases].map(c => ({ ...c, status: 'running' as const, progress: 0, results: [] as TestResult[] }));
setTestCases(updatedCases);
const baseUrl = difyConfig.apiUrl.replace(/\/$/, '');
@@ -316,6 +483,8 @@ export default function App() {
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++) {
@@ -326,7 +495,7 @@ export default function App() {
const testCase = updatedCases[i];
const times = testCase.times || 1;
const results = [];
const results: TestResult[] = [];
for (let t = 0; t < times; t++) {
if (abortControllerRef.current.signal.aborted) {
@@ -337,7 +506,7 @@ export default function App() {
try {
const body: any = {
inputs: testCase.inputs,
response_mode: 'blocking',
response_mode: useStreaming ? 'streaming' : 'blocking',
user: 'batch-test-user'
};
@@ -355,10 +524,37 @@ export default function App() {
signal: abortControllerRef.current.signal
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'API Error');
results.push(data);
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;
@@ -385,7 +581,6 @@ export default function App() {
setStep(3);
toast.success('批量测试已完成');
} else {
// Reset non-completed cases to idle
setTestCases(prev => prev.map(c => c.status === 'running' ? { ...c, status: 'idle' } : c));
toast.info('任务已终止');
}
@@ -393,6 +588,29 @@ export default function App() {
// 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> = {
@@ -404,29 +622,101 @@ export default function App() {
row['User Query'] = testCase.query || '';
}
Object.entries(testCase.inputs).forEach(([k, v]) => {
row[k] = v;
});
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 {
// Extract specific output if it's a workflow
const outputs = result.data?.outputs || result.answer || JSON.stringify(result);
if (typeof outputs === 'object') {
Object.entries(outputs).forEach(([k, v]) => {
row[`Output_${k}`] = typeof v === 'object' ? JSON.stringify(v) : v;
});
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 {
row['Output'] = outputs;
for (const k of outputKeys) row[`Output_${k}`] = '';
if (sawScalarOutput) row['Output'] = outputs ?? '';
}
row['Task ID'] = result.task_id || result.id;
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;
});
});
const worksheet = XLSX.utils.json_to_sheet(dataForExport);
// 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`);
@@ -450,13 +740,16 @@ export default function App() {
query: testCase.query,
isError: !!result.error,
output: outputString,
taskId: result.task_id || result.id || 'N/A'
taskId: result.task_id || result.id || 'N/A',
result
};
});
});
};
const ResultsTable = ({ flatResults, inputs, appMode }: { flatResults: any[], inputs: DifyInput[], appMode: AppMode }) => (
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]">
@@ -469,11 +762,17 @@ export default function App() {
<TableHead key={input.name} className="h-12 px-6 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">{input.label}</TableHead>
))}
<TableHead className="w-32 h-12 px-6 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
{showTrace && (
<TableHead className="w-[180px] h-12 px-6 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"></TableHead>
)}
<TableHead className="w-[420px] h-12 px-6 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider"> (Response Payload)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flatResults.map((row, idx) => (
{flatResults.map((row, idx) => {
const trace: NodeTraceStep[] | undefined = row.result?.trace;
const hasTrace = (trace?.length ?? 0) > 0;
return (
<TableRow key={idx} className="hover:bg-slate-50/50 border-b border-[#f1f5f9] transition-colors">
<TableCell className="px-6 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.caseIndex).padStart(2, '0')}</TableCell>
<TableCell className="px-6 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.iteration).padStart(2, '0')}</TableCell>
@@ -500,13 +799,41 @@ export default function App() {
</div>
)}
</TableCell>
{showTrace && (
<TableCell className="px-6 py-4 align-top">
{hasTrace ? (
<button
type="button"
onClick={() => setTraceDialogResult(row.result)}
className="group flex flex-col items-start gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-left transition-all hover:border-slate-300 hover:shadow-sm active:scale-[0.98]"
>
<span className="flex items-center gap-1.5 text-[10px] font-bold text-[#0f172a]">
<Activity className="h-3 w-3" />
{trace!.length}
<ChevronRight className="h-3 w-3 text-slate-300 group-hover:translate-x-0.5 transition-transform" />
</span>
<span className="flex items-center gap-2 font-mono text-[9px] text-[#94a3b8]">
{row.result?.elapsedTime != null && (
<span className="flex items-center gap-0.5"><Clock className="h-2.5 w-2.5" />{row.result.elapsedTime.toFixed(2)}s</span>
)}
{row.result?.totalTokens != null && (
<span className="flex items-center gap-0.5"><Coins className="h-2.5 w-2.5" />{row.result.totalTokens}</span>
)}
</span>
</button>
) : (
<span className="text-[10px] text-slate-300"></span>
)}
</TableCell>
)}
<TableCell className="px-6 py-4 align-top">
<div className="w-[420px] max-w-[420px] 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>
);
@@ -764,6 +1091,35 @@ export default function App() {
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}
@@ -867,14 +1223,24 @@ export default function App() {
<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>
) : (
<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 (${testCase.results.length}/${testCase.times})` : `Complete (${testCase.times}/${testCase.times})`}
</span>
</div>
)}
) : (() => {
const lastResult = testCase.results[testCase.results.length - 1];
const runningStep = testCase.status === 'running' && lastResult?.trace?.find(s => s.status === 'running');
const lastFinished = testCase.status === 'running' && lastResult?.trace?.slice().reverse().find(s => s.status !== 'running');
const currentNodeLabel = runningStep?.title || lastFinished?.title;
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 truncate max-w-[180px]">
{testCase.status === 'running'
? (currentNodeLabel
? `Node · ${currentNodeLabel}`
: `In Progress (${testCase.results.length}/${testCase.times})`)
: `Complete (${testCase.times}/${testCase.times})`}
</span>
</div>
);
})()}
</TableCell>
<TableCell className="px-6 py-4">
<Button
@@ -990,7 +1356,7 @@ export default function App() {
</div>
</div>
<div className="max-h-[600px] overflow-auto">
<ResultsTable flatResults={getFlatResults()} inputs={inputs} appMode={difyConfig.appMode} />
<ResultsTable flatResults={getFlatResults()} inputs={inputs} appMode={difyConfig.appMode} showTrace={anyTrace} />
</div>
</Card>
@@ -1021,6 +1387,179 @@ export default function App() {
</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;
}

View File

@@ -6,6 +6,47 @@ export interface DifyInput {
export type AppMode = 'workflow' | 'chat' | 'completion';
export type NodeStatus =
| 'running'
| 'succeeded'
| 'failed'
| 'stopped'
| 'exception'
| 'partial-succeeded';
export interface NodeTraceStep {
// stable id from node_started/node_finished events (data.id)
execId: string;
nodeId: string;
nodeType: string;
title: string;
index: number;
status: NodeStatus;
inputs?: any;
outputs?: any;
error?: string | null;
elapsedTime?: number;
tokens?: number;
startedAt?: number;
finishedAt?: number;
}
export interface TestResult {
// existing free-form payload from Dify (blocking response) or synthesized
data?: any;
answer?: string;
error?: string;
task_id?: string;
id?: string;
// trace metadata (only populated when streaming / capture-trace was on)
workflowRunId?: string;
trace?: NodeTraceStep[];
finalStatus?: NodeStatus | string;
totalTokens?: number;
elapsedTime?: number;
}
export interface TestCase {
id: string;
inputs: Record<string, any>;
@@ -13,7 +54,7 @@ export interface TestCase {
times: number;
status: 'idle' | 'running' | 'completed' | 'failed';
progress: number;
results: any[];
results: TestResult[];
}
export interface DifyConfig {