Add trace Excel sheet, full JSON export, and cell chunking for large I/O

Made-with: Cursor
This commit is contained in:
Xin Wang
2026-04-18 23:23:54 +08:00
parent 950604e0c7
commit 98b5e2a0da

View File

@@ -719,9 +719,120 @@ export default function App() {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Results');
// Second sheet: per-node execution detail for analysis.
// Only created when at least one iteration has trace data.
if (includeTrace) {
const traceHeader = [
'Case', 'Iter', 'Step',
'Node Type', 'Node Title', 'Node ID',
'Status', 'Elapsed (s)', 'Tokens', 'Error',
'Inputs', 'Outputs',
'Workflow Run ID', 'Cont.'
];
const traceRows: Record<string, any>[] = [];
testCases.forEach((c, i) => {
c.results.forEach((r, j) => {
if (!r.trace?.length) return;
r.trace.forEach(step => {
const inputsStr = step.inputs === undefined ? '' : safeStringifyForCell(step.inputs);
const outputsStr = step.outputs === undefined ? '' : safeStringifyForCell(step.outputs);
// A single node's input or output can exceed Excel's 32,767 char
// cell cap; chunk across sibling rows and annotate via "Cont.".
const inChunks = chunkForExcel(inputsStr);
const outChunks = chunkForExcel(outputsStr);
const chunkCount = Math.max(1, inChunks.length, outChunks.length);
for (let k = 0; k < chunkCount; k++) {
traceRows.push({
'Case': i + 1,
'Iter': j + 1,
'Step': step.index ?? '',
'Node Type': step.nodeType,
'Node Title': step.title,
'Node ID': step.nodeId,
'Status': step.status,
'Elapsed (s)': k === 0 && step.elapsedTime != null
? Number(step.elapsedTime.toFixed(3))
: '',
'Tokens': k === 0 ? (step.tokens ?? '') : '',
'Error': k === 0 ? (step.error ?? '') : '',
'Inputs': inChunks[k] ?? '',
'Outputs': outChunks[k] ?? '',
'Workflow Run ID': k === 0 ? (r.workflowRunId ?? '') : '',
'Cont.': chunkCount > 1 ? `${k + 1}/${chunkCount}` : ''
});
}
});
});
});
const traceSheet = XLSX.utils.json_to_sheet(traceRows, { header: traceHeader });
traceSheet['!cols'] = traceHeader.map(h => {
if (h === 'Inputs' || h === 'Outputs') return { wch: 60 };
if (h === 'Node Title' || h === 'Error') return { wch: 30 };
if (h === 'Workflow Run ID') return { wch: 38 };
if (h === 'Node ID') return { wch: 20 };
return { wch: 10 };
});
// Freeze the header row so the columns stay visible while scrolling.
traceSheet['!freeze'] = { xSplit: 0, ySplit: 1 } as any;
XLSX.utils.book_append_sheet(workbook, traceSheet, 'TraceSteps');
}
XLSX.writeFile(workbook, `dify_batch_test_${new Date().toISOString().split('T')[0]}.xlsx`);
};
// Step 3: Export full, lossless trace bundle as JSON for deep analysis.
const exportTraceJson = () => {
const bundle = {
exportedAt: new Date().toISOString(),
appMode: difyConfig.appMode,
apiUrl: difyConfig.apiUrl,
totals: {
cases: testCases.length,
iterations: testCases.reduce((a, c) => a + c.results.length, 0),
successes: testCases.reduce((a, c) => a + c.results.filter(r => !r.error).length, 0),
failures: testCases.reduce((a, c) => a + c.results.filter(r => !!r.error).length, 0)
},
cases: testCases.map((c, i) => ({
caseIndex: i + 1,
id: c.id,
inputs: c.inputs,
query: c.query,
times: c.times,
results: c.results.map((r, j) => ({
iteration: j + 1,
workflowRunId: r.workflowRunId,
taskId: r.task_id,
messageId: r.id,
finalStatus: r.finalStatus,
totalTokens: r.totalTokens,
elapsedTime: r.elapsedTime,
error: r.error,
answer: r.answer,
data: r.data,
trace: r.trace
}))
}))
};
const json = JSON.stringify(bundle, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dify_batch_trace_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('追踪数据已导出为 JSON');
};
const getFlatResults = () => {
return testCases.flatMap((testCase, index) => {
return testCase.results.map((result, rIndex) => {
@@ -1347,7 +1458,19 @@ export default function App() {
<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">
<div className="flex flex-wrap items-center gap-3">
{anyTrace && (
<Button
onClick={exportTraceJson}
size="sm"
variant="outline"
title="导出包含完整节点 I/O 的 JSON 文件用于深入分析"
className="h-10 px-6 gap-2 rounded-full border-slate-200 text-[#0f172a] hover:bg-slate-50 text-xs font-bold transition-all active:scale-[0.98]"
>
<Activity className="h-4 w-4" />
JSON
</Button>
)}
<Button
onClick={exportToExcel}
size="sm"
@@ -1518,6 +1641,29 @@ function safeStringify(v: unknown): string {
const EXCEL_CELL_LIMIT = 32000;
const TRACE_INLINE_JSON_LIMIT = 300;
// Same contract as safeStringify but compact (no indent) so per-step cells in
// the TraceSteps sheet pack more content per chunk.
function safeStringifyForCell(v: unknown): string {
try {
return typeof v === 'string' ? v : JSON.stringify(v);
} catch {
return String(v);
}
}
// Split a long string into consecutive substrings each within Excel's per-cell
// character limit. Returns [] for empty input so callers can render "" rather
// than a pointless chunked row.
function chunkForExcel(s: string): string[] {
if (!s) return [];
if (s.length <= EXCEL_CELL_LIMIT) return [s];
const chunks: string[] = [];
for (let i = 0; i < s.length; i += EXCEL_CELL_LIMIT) {
chunks.push(s.slice(i, i + EXCEL_CELL_LIMIT));
}
return chunks;
}
function briefInlineJson(v: unknown): string {
if (v == null) return '';
let s: string;