Add trace Excel sheet, full JSON export, and cell chunking for large I/O
Made-with: Cursor
This commit is contained in:
148
src/App.tsx
148
src/App.tsx
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user