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,
|
ShieldCheck,
|
||||||
Slash,
|
Slash,
|
||||||
Eye,
|
Eye,
|
||||||
Key
|
Key,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
Hash,
|
||||||
|
Coins,
|
||||||
|
AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
@@ -29,9 +34,167 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Label } from '@/components/ui/label';
|
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() {
|
export default function App() {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
@@ -39,12 +202,16 @@ export default function App() {
|
|||||||
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
const [isStopping, setIsStopping] = useState(false);
|
const [isStopping, setIsStopping] = useState(false);
|
||||||
|
const [captureTrace, setCaptureTrace] = useState(false);
|
||||||
|
const [traceDialogResult, setTraceDialogResult] = useState<TestResult | null>(null);
|
||||||
const [difyConfig, setDifyConfig] = useState<DifyConfig>({
|
const [difyConfig, setDifyConfig] = useState<DifyConfig>({
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiUrl: 'https://api.dify.ai/v1',
|
apiUrl: 'https://api.dify.ai/v1',
|
||||||
appMode: 'chat'
|
appMode: 'chat'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canCaptureTrace = difyConfig.appMode === 'workflow' || difyConfig.appMode === 'chat';
|
||||||
|
|
||||||
const excelInputRef = useRef<HTMLInputElement>(null);
|
const excelInputRef = useRef<HTMLInputElement>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -303,7 +470,7 @@ export default function App() {
|
|||||||
setIsStopping(false);
|
setIsStopping(false);
|
||||||
abortControllerRef.current = new AbortController();
|
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);
|
setTestCases(updatedCases);
|
||||||
|
|
||||||
const baseUrl = difyConfig.apiUrl.replace(/\/$/, '');
|
const baseUrl = difyConfig.apiUrl.replace(/\/$/, '');
|
||||||
@@ -316,6 +483,8 @@ export default function App() {
|
|||||||
endpoint = `${baseUrl}/workflows/run`;
|
endpoint = `${baseUrl}/workflows/run`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only workflow + advanced-chat emit node-level events. Completion apps don't.
|
||||||
|
const useStreaming = captureTrace && canCaptureTrace;
|
||||||
let stoppedManually = false;
|
let stoppedManually = false;
|
||||||
|
|
||||||
for (let i = 0; i < updatedCases.length; i++) {
|
for (let i = 0; i < updatedCases.length; i++) {
|
||||||
@@ -326,7 +495,7 @@ export default function App() {
|
|||||||
|
|
||||||
const testCase = updatedCases[i];
|
const testCase = updatedCases[i];
|
||||||
const times = testCase.times || 1;
|
const times = testCase.times || 1;
|
||||||
const results = [];
|
const results: TestResult[] = [];
|
||||||
|
|
||||||
for (let t = 0; t < times; t++) {
|
for (let t = 0; t < times; t++) {
|
||||||
if (abortControllerRef.current.signal.aborted) {
|
if (abortControllerRef.current.signal.aborted) {
|
||||||
@@ -337,7 +506,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const body: any = {
|
const body: any = {
|
||||||
inputs: testCase.inputs,
|
inputs: testCase.inputs,
|
||||||
response_mode: 'blocking',
|
response_mode: useStreaming ? 'streaming' : 'blocking',
|
||||||
user: 'batch-test-user'
|
user: 'batch-test-user'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,10 +524,37 @@ export default function App() {
|
|||||||
signal: abortControllerRef.current.signal
|
signal: abortControllerRef.current.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
if (useStreaming) {
|
||||||
if (!response.ok) throw new Error(data.message || 'API Error');
|
if (!response.ok || !response.body) {
|
||||||
|
let errMsg = `HTTP ${response.status}`;
|
||||||
results.push(data);
|
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) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
stoppedManually = true;
|
stoppedManually = true;
|
||||||
@@ -385,7 +581,6 @@ export default function App() {
|
|||||||
setStep(3);
|
setStep(3);
|
||||||
toast.success('批量测试已完成');
|
toast.success('批量测试已完成');
|
||||||
} else {
|
} else {
|
||||||
// Reset non-completed cases to idle
|
|
||||||
setTestCases(prev => prev.map(c => c.status === 'running' ? { ...c, status: 'idle' } : c));
|
setTestCases(prev => prev.map(c => c.status === 'running' ? { ...c, status: 'idle' } : c));
|
||||||
toast.info('任务已终止');
|
toast.info('任务已终止');
|
||||||
}
|
}
|
||||||
@@ -393,6 +588,29 @@ export default function App() {
|
|||||||
|
|
||||||
// Step 3: Export to Excel
|
// Step 3: Export to Excel
|
||||||
const exportToExcel = () => {
|
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) => {
|
const dataForExport = testCases.flatMap((testCase, index) => {
|
||||||
return testCase.results.map((result, rIndex) => {
|
return testCase.results.map((result, rIndex) => {
|
||||||
const row: Record<string, any> = {
|
const row: Record<string, any> = {
|
||||||
@@ -404,29 +622,101 @@ export default function App() {
|
|||||||
row['User Query'] = testCase.query || '';
|
row['User Query'] = testCase.query || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(testCase.inputs).forEach(([k, v]) => {
|
for (const k of inputKeys) {
|
||||||
row[k] = v;
|
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) {
|
if (result.error) {
|
||||||
row['Error'] = 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 {
|
} else {
|
||||||
// Extract specific output if it's a workflow
|
const outputs = result.data?.outputs ?? result.answer;
|
||||||
const outputs = result.data?.outputs || result.answer || JSON.stringify(result);
|
if (outputs && typeof outputs === 'object') {
|
||||||
if (typeof outputs === 'object') {
|
for (const k of outputKeys) {
|
||||||
Object.entries(outputs).forEach(([k, v]) => {
|
const v = (outputs as any)[k];
|
||||||
row[`Output_${k}`] = typeof v === 'object' ? JSON.stringify(v) : v;
|
row[`Output_${k}`] = v === undefined
|
||||||
});
|
? ''
|
||||||
|
: typeof v === 'object' ? JSON.stringify(v) : v;
|
||||||
|
}
|
||||||
|
if (sawScalarOutput) row['Output'] = '';
|
||||||
} else {
|
} 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;
|
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();
|
const workbook = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Results');
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Results');
|
||||||
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`);
|
||||||
@@ -450,13 +740,16 @@ export default function App() {
|
|||||||
query: testCase.query,
|
query: testCase.query,
|
||||||
isError: !!result.error,
|
isError: !!result.error,
|
||||||
output: outputString,
|
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>
|
<Table>
|
||||||
<TableHeader className="bg-[#f8fafc] sticky top-0 z-20">
|
<TableHeader className="bg-[#f8fafc] sticky top-0 z-20">
|
||||||
<TableRow className="hover:bg-transparent border-b border-[#e2e8f0]">
|
<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 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>
|
<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>
|
<TableHead className="w-[420px] h-12 px-6 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">响应内容 (Response Payload)</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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">
|
<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.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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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">
|
<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)]">
|
<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}
|
{row.output}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
@@ -764,6 +1091,35 @@ export default function App() {
|
|||||||
accept=".xlsx,.xls,.csv"
|
accept=".xlsx,.xls,.csv"
|
||||||
onChange={handleExcelImport}
|
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 ? (
|
{isExecuting ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={stopTests}
|
onClick={stopTests}
|
||||||
@@ -867,14 +1223,24 @@ export default function App() {
|
|||||||
<TableCell className="px-6 py-4">
|
<TableCell className="px-6 py-4">
|
||||||
{testCase.status === 'idle' ? (
|
{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>
|
<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">
|
const lastResult = testCase.results[testCase.results.length - 1];
|
||||||
<Progress value={testCase.progress} className="h-1.5 bg-[#f1f5f9] [&>div]:bg-[#0f172a] rounded-full" />
|
const runningStep = testCase.status === 'running' && lastResult?.trace?.find(s => s.status === 'running');
|
||||||
<span className="text-[9px] font-bold text-[#64748b] uppercase tracking-tighter">
|
const lastFinished = testCase.status === 'running' && lastResult?.trace?.slice().reverse().find(s => s.status !== 'running');
|
||||||
{testCase.status === 'running' ? `In Progress (${testCase.results.length}/${testCase.times})` : `Complete (${testCase.times}/${testCase.times})`}
|
const currentNodeLabel = runningStep?.title || lastFinished?.title;
|
||||||
</span>
|
return (
|
||||||
</div>
|
<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>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell className="px-6 py-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -990,7 +1356,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[600px] overflow-auto">
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -1021,6 +1387,179 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer Decoration REMOVED to match Sleek Interface minimal look */}
|
{/* 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>
|
</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 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 {
|
export interface TestCase {
|
||||||
id: string;
|
id: string;
|
||||||
inputs: Record<string, any>;
|
inputs: Record<string, any>;
|
||||||
@@ -13,7 +54,7 @@ export interface TestCase {
|
|||||||
times: number;
|
times: number;
|
||||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||||
progress: number;
|
progress: number;
|
||||||
results: any[];
|
results: TestResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DifyConfig {
|
export interface DifyConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user