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();
|
const workbook = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Results');
|
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`);
|
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 = () => {
|
const getFlatResults = () => {
|
||||||
return testCases.flatMap((testCase, index) => {
|
return testCases.flatMap((testCase, index) => {
|
||||||
return testCase.results.map((result, rIndex) => {
|
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">
|
<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">
|
<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>
|
<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
|
<Button
|
||||||
onClick={exportToExcel}
|
onClick={exportToExcel}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1518,6 +1641,29 @@ function safeStringify(v: unknown): string {
|
|||||||
const EXCEL_CELL_LIMIT = 32000;
|
const EXCEL_CELL_LIMIT = 32000;
|
||||||
const TRACE_INLINE_JSON_LIMIT = 300;
|
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 {
|
function briefInlineJson(v: unknown): string {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
let s: string;
|
let s: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user