Add Dify SSE streaming and node execution trace UI
Made-with: Cursor
This commit is contained in:
613
src/App.tsx
613
src/App.tsx
@@ -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;
|
||||
}
|
||||
|
||||
43
src/types.ts
43
src/types.ts
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user