1569 lines
68 KiB
TypeScript
1569 lines
68 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import {
|
||
Settings2,
|
||
Play,
|
||
Download,
|
||
Plus,
|
||
Trash2,
|
||
CheckCircle2,
|
||
Loader2,
|
||
ChevronRight,
|
||
AlertCircle,
|
||
Globe,
|
||
ShieldCheck,
|
||
Slash,
|
||
Eye,
|
||
Key,
|
||
Activity,
|
||
Clock,
|
||
Hash,
|
||
Coins,
|
||
AlertTriangle
|
||
} from 'lucide-react';
|
||
import yaml from 'js-yaml';
|
||
import * as XLSX from 'xlsx';
|
||
import { toast } from 'sonner';
|
||
|
||
import { Toaster } from '@/components/ui/sonner';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||
|
||
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);
|
||
const [inputs, setInputs] = useState<DifyInput[]>([]);
|
||
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);
|
||
|
||
// Step 1: Fetch Parameters from API
|
||
const fetchParameters = async () => {
|
||
if (!difyConfig.apiKey || !difyConfig.apiUrl) {
|
||
toast.error('API Key 和 API URL 必填');
|
||
return;
|
||
}
|
||
|
||
setIsExecuting(true);
|
||
const baseUrl = difyConfig.apiUrl.replace(/\/$/, '');
|
||
|
||
try {
|
||
// authoritative app mode detection using /info
|
||
const infoResponse = await fetch(`${baseUrl}/info`, {
|
||
headers: { 'Authorization': `Bearer ${difyConfig.apiKey}` }
|
||
});
|
||
|
||
let detectedMode: AppMode = 'workflow';
|
||
if (infoResponse.ok) {
|
||
const infoData = await infoResponse.json();
|
||
// authoritative check: advanced-chat or workflow
|
||
if (infoData.mode === 'advanced-chat') {
|
||
detectedMode = 'chat';
|
||
} else {
|
||
detectedMode = 'workflow';
|
||
}
|
||
}
|
||
|
||
if (detectedMode !== difyConfig.appMode) {
|
||
toast.error(`模式冲突:检测到此应用为 ${detectedMode === 'chat' ? 'Advanced-Chat/ChatFlow' : 'Workflow'} 类型,而您选择了 ${difyConfig.appMode.toUpperCase()}。请更正后重试。`);
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(`${baseUrl}/parameters`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${difyConfig.apiKey}`
|
||
}
|
||
});
|
||
|
||
if (response.status === 404) {
|
||
setInputs([]);
|
||
setTestCases([{
|
||
id: crypto.randomUUID(),
|
||
inputs: {},
|
||
times: 1,
|
||
status: 'idle',
|
||
progress: 0,
|
||
results: []
|
||
}]);
|
||
setStep(2);
|
||
toast.success(`已成功连接到 ${detectedMode === 'chat' ? 'Advanced-Chat' : 'Workflow'} 应用`);
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.message || 'Failed to fetch parameters');
|
||
|
||
const extractedInputs: DifyInput[] = data.user_input_form?.map((item: any) => {
|
||
const type = Object.keys(item)[0];
|
||
const config = item[type];
|
||
return {
|
||
name: config.variable,
|
||
type: type,
|
||
label: config.label || config.variable
|
||
};
|
||
}) || [];
|
||
|
||
if (extractedInputs.length === 0) {
|
||
toast.info('未发现用户输入字段,但配置已加载。');
|
||
}
|
||
|
||
setInputs(extractedInputs);
|
||
// Step 2 starts with an empty table as requested
|
||
setTestCases([]);
|
||
setStep(2);
|
||
toast.success('API 连接成功,配置已加载');
|
||
} catch (error: any) {
|
||
console.error('Fetch error:', error);
|
||
toast.error(error.message || '连接失败');
|
||
} finally {
|
||
setIsExecuting(false);
|
||
}
|
||
};
|
||
|
||
// Step 2: Manage Test Cases
|
||
const downloadExcelTemplate = () => {
|
||
try {
|
||
if (inputs.length === 0 && difyConfig.appMode !== 'chat') {
|
||
toast.error('未检测到输入变量');
|
||
return;
|
||
}
|
||
|
||
// Generate headers based on app mode and inputs
|
||
const headers: string[] = [];
|
||
if (difyConfig.appMode === 'chat') {
|
||
headers.push('query');
|
||
}
|
||
|
||
inputs.forEach(input => {
|
||
headers.push(input.label || input.name);
|
||
});
|
||
|
||
headers.push('times');
|
||
|
||
// Create a single empty row as example
|
||
const exampleRow = headers.map(h => {
|
||
if (h === 'times') return 1;
|
||
return '';
|
||
});
|
||
|
||
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, "测试模板");
|
||
|
||
// Filename includes app mode for clarity
|
||
const filename = `dify_${difyConfig.appMode}_template.xlsx`;
|
||
XLSX.writeFile(wb, filename);
|
||
toast.success('模板已生成并下载');
|
||
} catch (error) {
|
||
console.error('Template Download Error:', error);
|
||
toast.error('模板生成失败');
|
||
}
|
||
};
|
||
|
||
const handleExcelImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
try {
|
||
const data = new Uint8Array(event.target?.result as ArrayBuffer);
|
||
const workbook = XLSX.read(data, { type: 'array' });
|
||
const sheetName = workbook.SheetNames[0];
|
||
const worksheet = workbook.Sheets[sheetName];
|
||
const jsonData = XLSX.utils.sheet_to_json(worksheet) as any[];
|
||
|
||
if (jsonData.length === 0) {
|
||
toast.error('Excel 文件为空');
|
||
return;
|
||
}
|
||
|
||
const newTestCases: TestCase[] = jsonData.map(row => {
|
||
const testCaseInputs: Record<string, any> = {};
|
||
inputs.forEach(input => {
|
||
// Match column by label or name
|
||
const value = row[input.label] ?? row[input.name] ?? '';
|
||
testCaseInputs[input.name] = value;
|
||
});
|
||
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
inputs: testCaseInputs,
|
||
query: difyConfig.appMode === 'chat' ? (row['query'] || row['Query'] || '') : undefined,
|
||
times: parseInt(row['times'] || row['Times'] || '1') || 1,
|
||
status: 'idle',
|
||
progress: 0,
|
||
results: []
|
||
};
|
||
});
|
||
|
||
setTestCases(prev => [...prev, ...newTestCases]);
|
||
toast.success(`已从 Excel 导入 ${newTestCases.length} 个测试用例`);
|
||
// Reset input value to allow re-upload of same file
|
||
if (excelInputRef.current) excelInputRef.current.value = '';
|
||
} catch (error) {
|
||
console.error('Excel import error:', error);
|
||
toast.error('Excel 文件解析失败');
|
||
}
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
};
|
||
|
||
const addTestCase = () => {
|
||
const newCase: TestCase = {
|
||
id: crypto.randomUUID(),
|
||
inputs: {},
|
||
query: difyConfig.appMode === 'chat' ? '' : undefined,
|
||
times: 1,
|
||
status: 'idle',
|
||
progress: 0,
|
||
results: []
|
||
};
|
||
inputs.forEach(input => {
|
||
newCase.inputs[input.name] = '';
|
||
});
|
||
setTestCases([...testCases, newCase]);
|
||
};
|
||
|
||
const addVariable = (name: string) => {
|
||
if (!name || inputs.some(i => i.name === name)) return;
|
||
const newVariable: DifyInput = {
|
||
name,
|
||
type: 'text',
|
||
label: name
|
||
};
|
||
setInputs([...inputs, newVariable]);
|
||
// Also update existing test cases to have this input key
|
||
setTestCases(testCases.map(c => ({
|
||
...c,
|
||
inputs: { ...c.inputs, [name]: '' }
|
||
})));
|
||
};
|
||
|
||
const removeVariable = (name: string) => {
|
||
setInputs(inputs.filter(i => i.name !== name));
|
||
setTestCases(testCases.map(c => {
|
||
const newInputs = { ...c.inputs };
|
||
delete newInputs[name];
|
||
return { ...c, inputs: newInputs };
|
||
}));
|
||
};
|
||
|
||
const removeTestCase = (id: string) => {
|
||
setTestCases(testCases.filter(c => c.id !== id));
|
||
};
|
||
|
||
const updateTestCase = (id: string, field: string, value: any, isInput = true) => {
|
||
setTestCases(testCases.map(c => {
|
||
if (c.id === id) {
|
||
if (field === 'query') {
|
||
return { ...c, query: value };
|
||
}
|
||
if (isInput) {
|
||
return { ...c, inputs: { ...c.inputs, [field]: value } };
|
||
} else {
|
||
return { ...c, [field]: value };
|
||
}
|
||
}
|
||
return c;
|
||
}));
|
||
};
|
||
|
||
const stopTests = () => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
setIsStopping(true);
|
||
toast.info('正在停止任务...');
|
||
}
|
||
};
|
||
|
||
const runTests = async () => {
|
||
if (!difyConfig.apiKey) {
|
||
toast.error('必须输入 Dify API Key');
|
||
return;
|
||
}
|
||
|
||
if (testCases.length === 0) {
|
||
toast.error('请先添加至少一个测试用例');
|
||
return;
|
||
}
|
||
|
||
setIsExecuting(true);
|
||
setIsStopping(false);
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
const updatedCases = [...testCases].map(c => ({ ...c, status: 'running' as const, progress: 0, results: [] as TestResult[] }));
|
||
setTestCases(updatedCases);
|
||
|
||
const baseUrl = difyConfig.apiUrl.replace(/\/$/, '');
|
||
let endpoint = '';
|
||
if (difyConfig.appMode === 'chat') {
|
||
endpoint = `${baseUrl}/chat-messages`;
|
||
} else if (difyConfig.appMode === 'completion') {
|
||
endpoint = `${baseUrl}/completion-messages`;
|
||
} else {
|
||
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++) {
|
||
if (abortControllerRef.current.signal.aborted) {
|
||
stoppedManually = true;
|
||
break;
|
||
}
|
||
|
||
const testCase = updatedCases[i];
|
||
const times = testCase.times || 1;
|
||
const results: TestResult[] = [];
|
||
|
||
for (let t = 0; t < times; t++) {
|
||
if (abortControllerRef.current.signal.aborted) {
|
||
stoppedManually = true;
|
||
break;
|
||
}
|
||
|
||
try {
|
||
const body: any = {
|
||
inputs: testCase.inputs,
|
||
response_mode: useStreaming ? 'streaming' : 'blocking',
|
||
user: 'batch-test-user'
|
||
};
|
||
|
||
if (difyConfig.appMode === 'chat') {
|
||
body.query = testCase.query || '';
|
||
}
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${difyConfig.apiKey}`
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: abortControllerRef.current.signal
|
||
});
|
||
|
||
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;
|
||
break;
|
||
}
|
||
console.error(`Error in test case ${i + 1}, iteration ${t + 1}:`, error);
|
||
results.push({ error: error.message });
|
||
}
|
||
|
||
const currentProgress = Math.round(((t + 1) / times) * 100);
|
||
setTestCases(prev => prev.map(c => c.id === testCase.id ? { ...c, progress: currentProgress, results } : c));
|
||
}
|
||
|
||
if (stoppedManually) break;
|
||
|
||
setTestCases(prev => prev.map(c => c.id === testCase.id ? { ...c, status: 'completed', progress: 100 } : c));
|
||
}
|
||
|
||
setIsExecuting(false);
|
||
setIsStopping(false);
|
||
abortControllerRef.current = null;
|
||
|
||
if (!stoppedManually) {
|
||
setStep(3);
|
||
toast.success('批量测试已完成');
|
||
} else {
|
||
setTestCases(prev => prev.map(c => c.status === 'running' ? { ...c, status: 'idle' } : c));
|
||
toast.info('任务已终止');
|
||
}
|
||
};
|
||
|
||
// 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> = {
|
||
'Test Case': index + 1,
|
||
'Iteration': rIndex + 1,
|
||
};
|
||
|
||
if (difyConfig.appMode === 'chat') {
|
||
row['User Query'] = testCase.query || '';
|
||
}
|
||
|
||
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 {
|
||
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 {
|
||
for (const k of outputKeys) row[`Output_${k}`] = '';
|
||
if (sawScalarOutput) row['Output'] = outputs ?? '';
|
||
}
|
||
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;
|
||
});
|
||
});
|
||
|
||
// 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`);
|
||
};
|
||
|
||
const getFlatResults = () => {
|
||
return testCases.flatMap((testCase, index) => {
|
||
return testCase.results.map((result, rIndex) => {
|
||
const outputs = result.error
|
||
? result.error
|
||
: (result.data?.outputs || result.answer || JSON.stringify(result));
|
||
|
||
const outputString = typeof outputs === 'object'
|
||
? JSON.stringify(outputs, null, 2)
|
||
: String(outputs);
|
||
|
||
return {
|
||
caseIndex: index + 1,
|
||
iteration: rIndex + 1,
|
||
inputs: testCase.inputs,
|
||
query: testCase.query,
|
||
isError: !!result.error,
|
||
output: outputString,
|
||
taskId: result.task_id || result.id || 'N/A',
|
||
result
|
||
};
|
||
});
|
||
});
|
||
};
|
||
|
||
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]">
|
||
<TableHead className="w-14 h-12 px-3 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">序号</TableHead>
|
||
<TableHead className="w-14 h-12 px-3 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">迭代</TableHead>
|
||
{appMode === 'chat' && (
|
||
<TableHead className="min-w-[140px] max-w-[220px] h-12 px-4 font-bold text-[10px] text-[#0f172a] uppercase tracking-wider">用户提问</TableHead>
|
||
)}
|
||
{inputs.map(input => (
|
||
<TableHead key={input.name} className="min-w-[90px] max-w-[160px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider truncate">{input.label}</TableHead>
|
||
))}
|
||
<TableHead className="w-24 h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">状态</TableHead>
|
||
{showTrace && (
|
||
<TableHead className="w-[96px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">追踪</TableHead>
|
||
)}
|
||
<TableHead className="w-[320px] h-12 px-4 font-bold text-[10px] text-[#94a3b8] uppercase tracking-wider">响应内容</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{flatResults.map((row, idx) => {
|
||
const trace: NodeTraceStep[] | undefined = row.result?.trace;
|
||
const hasTrace = (trace?.length ?? 0) > 0;
|
||
const traceTooltipParts: string[] = [];
|
||
if (row.result?.elapsedTime != null) traceTooltipParts.push(`耗时 ${row.result.elapsedTime.toFixed(2)}s`);
|
||
if (row.result?.totalTokens != null) traceTooltipParts.push(`Tokens ${row.result.totalTokens}`);
|
||
return (
|
||
<TableRow key={idx} className="hover:bg-slate-50/50 border-b border-[#f1f5f9] transition-colors">
|
||
<TableCell className="px-3 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.caseIndex).padStart(2, '0')}</TableCell>
|
||
<TableCell className="px-3 py-4 font-mono text-[10px] font-bold text-[#94a3b8]">{String(row.iteration).padStart(2, '0')}</TableCell>
|
||
{appMode === 'chat' && (
|
||
<TableCell className="px-4 py-4 text-xs font-medium text-[#0f172a] max-w-[220px] truncate" title={row.query || ''}>
|
||
{row.query || '-'}
|
||
</TableCell>
|
||
)}
|
||
{inputs.map(input => (
|
||
<TableCell key={input.name} className="px-4 py-4 text-xs text-[#64748b] max-w-[160px] truncate" title={String(row.inputs[input.name] ?? '')}>
|
||
{String(row.inputs[input.name] || '-')}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="px-4 py-4">
|
||
{row.isError ? (
|
||
<div className="flex items-center gap-1.5 text-red-500 font-bold text-[9px] uppercase tracking-tight">
|
||
<div className="h-1 w-1 rounded-full bg-red-500" />
|
||
失败
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1.5 text-[#10b981] font-bold text-[9px] uppercase tracking-tight">
|
||
<div className="h-1 w-1 rounded-full bg-[#10b981]" />
|
||
成功
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
{showTrace && (
|
||
<TableCell className="px-4 py-4 align-middle">
|
||
{hasTrace ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setTraceDialogResult(row.result)}
|
||
title={traceTooltipParts.length ? `${trace!.length} 步 · ${traceTooltipParts.join(' · ')}` : `${trace!.length} 步`}
|
||
className="group inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left transition-all hover:border-slate-300 hover:shadow-sm active:scale-[0.98]"
|
||
>
|
||
<Activity className="h-3 w-3 text-[#0f172a]" />
|
||
<span className="text-[10px] font-bold text-[#0f172a]">{trace!.length} 步</span>
|
||
<ChevronRight className="h-3 w-3 text-slate-300 group-hover:translate-x-0.5 transition-transform" />
|
||
</button>
|
||
) : (
|
||
<span className="text-[10px] text-slate-300">—</span>
|
||
)}
|
||
</TableCell>
|
||
)}
|
||
<TableCell className="px-4 py-4 align-top">
|
||
<div className="w-[300px] max-w-[300px] 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>
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#f8fafc] font-sans text-[#020817] antialiased">
|
||
<Toaster position="top-center" />
|
||
|
||
{/* Header / Stepper */}
|
||
<header className="sticky top-0 z-50 border-b border-[#e2e8f0] bg-white/80 backdrop-blur-md py-3.5">
|
||
<div className="relative mx-auto flex w-full max-w-7xl items-center justify-between px-6 sm:px-8 lg:px-10">
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-[#0f172a] shadow-lg shadow-slate-200">
|
||
<span className="text-[10px] font-bold text-white tracking-tighter">BT</span>
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<h1 className="text-sm font-bold tracking-tight text-[#020817] leading-none">Dify Batch Tester</h1>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Central Progress Stepper */}
|
||
<div className="absolute left-1/2 hidden -translate-x-1/2 items-center justify-center gap-1.5 md:flex">
|
||
{[
|
||
{ id: 1, label: 'API 连接' },
|
||
{ id: 2, label: '配置与执行' },
|
||
{ id: 3, label: '下载结果' }
|
||
].map((s, idx) => {
|
||
const isPast = step > s.id;
|
||
const isCurrent = step === s.id;
|
||
const canClick = !isExecuting && !isStopping && (isPast || isCurrent);
|
||
|
||
return (
|
||
<div key={s.id} className="flex items-center">
|
||
<button
|
||
onClick={() => canClick && setStep(s.id)}
|
||
disabled={!canClick}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-200 ${
|
||
canClick ? 'hover:bg-slate-50 cursor-pointer active:scale-95' : 'cursor-not-allowed'
|
||
}`}
|
||
>
|
||
<div className={`flex h-5 w-5 items-center justify-center rounded-full border text-[9px] font-bold transition-all duration-500 ${
|
||
isCurrent ? 'border-[#0f172a] bg-[#0f172a] text-white ring-4 ring-slate-100 shadow-sm' :
|
||
isPast ? 'border-[#10b981] bg-[#10b981] text-white' : 'border-[#e2e8f0] bg-white text-[#94a3b8]'
|
||
}`}>
|
||
{isPast ? '✓' : s.id}
|
||
</div>
|
||
<span className={`hidden text-[11px] font-semibold whitespace-nowrap tracking-tight transition-colors duration-300 lg:inline ${
|
||
isCurrent ? 'text-[#0f172a]' : 'text-[#64748b]'
|
||
}`}>
|
||
{s.label}
|
||
</span>
|
||
</button>
|
||
{idx < 2 && (
|
||
<div className="flex items-center px-2">
|
||
<div className={`h-[1px] w-4 transition-colors duration-500 ${step > s.id ? 'bg-[#10b981]' : 'bg-[#e2e8f0]'}`} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<div className="flex items-center gap-2 bg-[#f8fafc] border border-[#e2e8f0] px-2.5 py-1 rounded-md shadow-[inset_0_1px_1px_rgba(0,0,0,0.01)]">
|
||
<div className={`h-1.5 w-1.5 rounded-full animate-pulse ${inputs.length > 0 ? 'bg-[#10b981]' : 'bg-amber-400'}`} />
|
||
<div className="font-mono text-[9px] font-bold tracking-tight text-[#475569] uppercase">
|
||
{inputs.length > 0 ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-[#94a3b8]">{difyConfig.appMode.toUpperCase()}</span>
|
||
<span className="h-2.5 w-[1px] bg-[#e2e8f0]" />
|
||
<span>{inputs.length} 个字段</span>
|
||
</span>
|
||
) : '等待连接'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main
|
||
className={`mx-auto flex w-full max-w-7xl flex-col px-6 py-10 sm:px-8 lg:px-10 ${
|
||
step === 1 ? 'min-h-[calc(100vh-65px)] justify-center' : 'gap-8'
|
||
}`}
|
||
>
|
||
|
||
<AnimatePresence mode="wait">
|
||
{step === 1 && (
|
||
<motion.div
|
||
key="step1"
|
||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: -10, scale: 0.98 }}
|
||
className="flex w-full flex-col items-center justify-center gap-12 lg:flex-row lg:gap-20"
|
||
>
|
||
<div className="text-left max-w-[440px] lg:max-w-[400px]">
|
||
<div className="mb-6 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-[#0f172a] text-white shadow-lg shadow-slate-200">
|
||
<Play className="h-5 w-5 fill-current" />
|
||
</div>
|
||
<h2 className="text-4xl font-bold tracking-tight text-[#0f172a] mb-6 leading-[1.2]">
|
||
Dify 批量自动化<br />测试终端
|
||
</h2>
|
||
<div className="space-y-4">
|
||
<p className="text-base text-slate-500 leading-relaxed font-medium">
|
||
同步您的 Dify 应用配置,通过自动化批量运行实时评估提示词(Prompt)表现。支持大规模数据批处理与结果导出。
|
||
</p>
|
||
<p className="text-sm text-slate-400 leading-relaxed">
|
||
助力开发者精细化调优 LLM 任务,确保应用在海量数据输入下的稳定性与可靠性。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="w-full max-w-[440px] border-[#e2e8f0] bg-white shadow-2xl shadow-slate-200/60 rounded-[40px] overflow-hidden border-t border-l border-white/50">
|
||
<div className="p-10 space-y-10">
|
||
{/* Mode Selection Toggle */}
|
||
<div className="flex flex-col items-center gap-5">
|
||
<div className="bg-[#f1f5f9] p-1 rounded-full flex gap-1 border border-[#e2e8f0]">
|
||
{(['chat', 'workflow'] as AppMode[]).map((mode) => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => setDifyConfig({ ...difyConfig, appMode: mode })}
|
||
className={`px-6 py-2 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all duration-300 ${
|
||
difyConfig.appMode === mode
|
||
? 'bg-white text-[#0f172a] shadow-sm ring-1 ring-slate-200/50'
|
||
: 'text-[#64748b] hover:text-[#0f172a]'
|
||
}`}
|
||
>
|
||
{mode === 'chat' ? '高级对话 (Chat)' : '工作流 (Workflow)'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input Rows */}
|
||
<div className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="step1-apiUrl" className="text-[10px] pl-1 font-bold text-[#94a3b8] uppercase tracking-[0.1em]">API 端点 URL</Label>
|
||
<div className="relative group">
|
||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#cbd5e1] group-focus-within:text-[#64748b] transition-colors">
|
||
<Globe className="h-4 w-4" />
|
||
</div>
|
||
<Input
|
||
id="step1-apiUrl"
|
||
value={difyConfig.apiUrl}
|
||
onChange={(e) => setDifyConfig({ ...difyConfig, apiUrl: e.target.value })}
|
||
placeholder="https://api.dify.ai/v1"
|
||
className="h-12 pl-10 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-slate-200 transition-all font-medium placeholder:text-slate-300"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="step1-apiKey" className="text-[10px] pl-1 font-bold text-[#94a3b8] uppercase tracking-[0.1em]">应用密钥 (API Key)</Label>
|
||
<div className="relative group">
|
||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#cbd5e1] group-focus-within:text-[#64748b] transition-colors">
|
||
<Key className="h-4 w-4" />
|
||
</div>
|
||
<Input
|
||
id="step1-apiKey"
|
||
type="password"
|
||
value={difyConfig.apiKey}
|
||
onChange={(e) => setDifyConfig({ ...difyConfig, apiKey: e.target.value })}
|
||
placeholder="app-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||
className="h-12 pl-10 bg-slate-50 border-none rounded-xl focus:ring-2 focus:ring-slate-200 transition-all font-medium placeholder:text-slate-300"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<Button
|
||
onClick={fetchParameters}
|
||
disabled={isExecuting}
|
||
className="w-full h-14 bg-[#0f172a] hover:bg-[#1e293b] text-white font-bold rounded-2xl gap-3 shadow-xl shadow-slate-200 transition-all active:scale-[0.98]"
|
||
>
|
||
{isExecuting ? <Loader2 className="h-5 w-5 animate-spin" /> : <ChevronRight className="h-5 w-5" />}
|
||
{isExecuting ? '正在连接...' : '连接到 Dify 应用'}
|
||
</Button>
|
||
|
||
<div className="flex items-center justify-center gap-2 text-[10px] font-medium text-[#94a3b8]">
|
||
<ShieldCheck className="h-3 w-3" />
|
||
<span>模式将通过 API 响应自动验证</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 2 && (
|
||
<motion.div
|
||
key="step2"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
className="space-y-8"
|
||
>
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-3xl font-bold tracking-tight text-[#0f172a]">配置测试用例</h2>
|
||
<p className="text-slate-500 mt-2 text-sm font-medium">为每个测试用例填写输入字段并指定重复次数。</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button
|
||
onClick={() => {
|
||
setTestCases([{
|
||
id: crypto.randomUUID(),
|
||
inputs: Object.fromEntries(inputs.map(i => [i.name, ''])),
|
||
times: 1,
|
||
status: 'idle',
|
||
progress: 0,
|
||
results: []
|
||
}]);
|
||
toast.success('测试用例已清空');
|
||
}}
|
||
variant="ghost"
|
||
disabled={isExecuting}
|
||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-full h-10 px-4 transition-all"
|
||
>
|
||
全部清除
|
||
</Button>
|
||
<div className="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm h-10">
|
||
<Button
|
||
onClick={addTestCase}
|
||
variant="ghost"
|
||
disabled={isExecuting}
|
||
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#0f172a]"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
添加
|
||
</Button>
|
||
<Button
|
||
onClick={() => excelInputRef.current?.click()}
|
||
variant="ghost"
|
||
disabled={isExecuting}
|
||
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#64748b] border-l border-slate-100"
|
||
>
|
||
<Download className="h-4 w-4 rotate-180" />
|
||
导入
|
||
</Button>
|
||
<Button
|
||
onClick={downloadExcelTemplate}
|
||
variant="ghost"
|
||
disabled={isExecuting}
|
||
className="gap-2 rounded-full h-full px-4 text-xs font-bold text-[#64748b] border-l border-slate-100"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
模板
|
||
</Button>
|
||
</div>
|
||
<input
|
||
type="file"
|
||
ref={excelInputRef}
|
||
className="hidden"
|
||
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}
|
||
disabled={isStopping}
|
||
variant="destructive"
|
||
className="gap-2 rounded-full h-10 px-6 shadow-lg shadow-red-100 transition-all font-bold"
|
||
>
|
||
{isStopping ? <Loader2 className="h-4 w-4 animate-spin" /> : <Slash className="h-4 w-4" />}
|
||
{isStopping ? '停止中' : '终止任务'}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
onClick={runTests}
|
||
disabled={isExecuting || testCases.length === 0}
|
||
className="gap-2 bg-[#0f172a] hover:bg-[#1e293b] rounded-full h-10 px-8 shadow-lg shadow-slate-200 transition-all font-bold"
|
||
>
|
||
<Play className="h-4 w-4" />
|
||
开始运行
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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-5 bg-white">
|
||
<h3 className="text-base font-bold text-[#0f172a]">用例矩阵</h3>
|
||
<div className="flex items-center gap-2 bg-[#f8fafc] border border-[#e2e8f0] px-3 py-1 rounded-full">
|
||
<div className="h-1.5 w-1.5 rounded-full bg-[#10b981]" />
|
||
<div className="font-mono text-[10px] font-bold tracking-tight text-[#475569] uppercase flex items-center gap-2">
|
||
<span>{difyConfig.appMode}</span>
|
||
<span className="h-2.5 w-[1px] bg-[#e2e8f0]" />
|
||
<span>{testCases.length} CASE(S)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
{testCases.length > 0 && (
|
||
<TableHeader className="bg-[#f1f5f9]">
|
||
<TableRow className="hover:bg-transparent border-b border-[#e2e8f0]">
|
||
<TableHead className="w-16 h-10 px-6 font-medium text-[#64748b]">序号</TableHead>
|
||
{difyConfig.appMode === 'chat' && (
|
||
<TableHead className="min-w-[200px] h-10 px-6 font-semibold text-[#0f172a]">User Query</TableHead>
|
||
)}
|
||
{inputs.map(input => (
|
||
<TableHead key={input.name} className="h-10 px-6 font-medium text-[#64748b]">
|
||
<div className="flex items-center justify-between group">
|
||
<span>{input.label}</span>
|
||
<button
|
||
onClick={() => removeVariable(input.name)}
|
||
className="ml-2 opacity-0 group-hover:opacity-100 transition-opacity hover:text-red-500"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</TableHead>
|
||
))}
|
||
<TableHead className="w-24 h-10 px-6 text-center font-medium text-[#64748b]">次数</TableHead>
|
||
<TableHead className="w-48 h-10 px-6 font-medium text-[#64748b]">执行进度</TableHead>
|
||
<TableHead className="w-16 h-10 px-6 font-medium text-[#64748b]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
)}
|
||
<TableBody>
|
||
{testCases.length > 0 ? (
|
||
testCases.map((testCase, index) => (
|
||
<TableRow key={testCase.id} className="hover:bg-[#f8fafc]/50 border-b border-[#e2e8f0]">
|
||
<TableCell className="px-6 font-mono text-sm text-[#64748b]">{String(index + 1).padStart(2, '0')}</TableCell>
|
||
{difyConfig.appMode === 'chat' && (
|
||
<TableCell className="px-6 py-4">
|
||
<Input
|
||
placeholder="输入用户消息..."
|
||
disabled={isExecuting}
|
||
value={testCase.query || ''}
|
||
onChange={(e) => updateTestCase(testCase.id, 'query', e.target.value)}
|
||
className="border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 transition-all h-9 rounded-lg font-medium placeholder:text-slate-300 text-sm"
|
||
/>
|
||
</TableCell>
|
||
)}
|
||
{inputs.map(input => (
|
||
<TableCell key={input.name} className="px-6 py-4">
|
||
<Input
|
||
placeholder={`输入 ${input.label}`}
|
||
disabled={isExecuting}
|
||
value={testCase.inputs[input.name] || ''}
|
||
onChange={(e) => updateTestCase(testCase.id, input.name, e.target.value)}
|
||
className="border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 transition-all h-9 rounded-lg font-medium placeholder:text-slate-300 text-sm"
|
||
/>
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="px-6 py-4">
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
disabled={isExecuting}
|
||
value={testCase.times}
|
||
onChange={(e) => updateTestCase(testCase.id, 'times', parseInt(e.target.value) || 1, false)}
|
||
className="w-20 text-center border-none bg-slate-50 focus:ring-2 focus:ring-slate-100 h-9 rounded-lg font-mono font-bold"
|
||
/>
|
||
</TableCell>
|
||
<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>
|
||
) : (() => {
|
||
const lastResult = testCase.results[testCase.results.length - 1];
|
||
// Only surface the node label while a step is actively running,
|
||
// so it clears between iterations instead of showing a stale one.
|
||
const runningStep =
|
||
testCase.status === 'running'
|
||
? lastResult?.trace?.find(s => s.status === 'running')
|
||
: undefined;
|
||
const done = testCase.results.length;
|
||
const total = testCase.times;
|
||
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">
|
||
{testCase.status === 'running'
|
||
? `In Progress (${done}/${total})`
|
||
: `Complete (${total}/${total})`}
|
||
</span>
|
||
{runningStep?.title && (
|
||
<span className="flex items-center gap-1 text-[9px] text-slate-400 max-w-[200px]">
|
||
<Activity className="h-2.5 w-2.5 shrink-0" />
|
||
<span className="truncate">{runningStep.title}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</TableCell>
|
||
<TableCell className="px-6 py-4">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={isExecuting}
|
||
onClick={() => removeTestCase(testCase.id)}
|
||
className="text-[#64748b] hover:text-red-500 hover:bg-red-50"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell colSpan={10} className="h-64 text-center">
|
||
<div className="flex flex-col items-center justify-center gap-4">
|
||
<div className="h-12 w-12 rounded-full bg-slate-50 flex items-center justify-center text-slate-300">
|
||
<Plus className="h-6 w-6" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-medium text-slate-900">暂无测试用例</p>
|
||
<p className="text-xs text-slate-500">点击下方按钮手动添加或从 Excel 导入</p>
|
||
</div>
|
||
<Button
|
||
onClick={addTestCase}
|
||
size="sm"
|
||
className="mt-2 bg-[#0f172a] hover:bg-[#1e293b] gap-2 rounded-full h-10 px-6 font-bold shadow-lg shadow-slate-100 transition-all"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
添加首个用例
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</Card>
|
||
|
||
{testCases.length > 5 && (
|
||
<div className="flex justify-center">
|
||
<Button
|
||
onClick={addTestCase}
|
||
variant="ghost"
|
||
className="gap-2 text-slate-400 hover:text-[#0f172a] hover:bg-slate-50 rounded-full px-6 transition-all font-bold text-xs"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
继续添加用例
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<motion.div
|
||
key="step3"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
className="space-y-10"
|
||
>
|
||
<div className="text-center">
|
||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-green-50 text-[#10b981]">
|
||
<CheckCircle2 className="h-10 w-10" />
|
||
</div>
|
||
<h2 className="text-4xl font-bold tracking-tight text-[#020817]">执行完成</h2>
|
||
<p className="text-[#64748b] text-lg mt-4 max-w-2xl mx-auto leading-relaxed">
|
||
所有 {testCases.length} 个测试用例已成功执行。结果已在下方展示,您可以进行预览或直接下载导出。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full">
|
||
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
|
||
<CardHeader className="pb-3 px-8 pt-8">
|
||
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]">总测试用例数</CardDescription>
|
||
<CardTitle className="text-4xl font-bold text-[#020817] font-mono">{testCases.length}</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
|
||
<CardHeader className="pb-3 px-8 pt-8">
|
||
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]">成功执行次数</CardDescription>
|
||
<CardTitle className="text-4xl font-bold text-[#10b981] font-mono">
|
||
{testCases.reduce((acc, c) => acc + c.results.filter(r => !r.error).length, 0)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
<Card className="bg-white border-[#e2e8f0] rounded-[24px] shadow-xl shadow-slate-200/40 overflow-hidden">
|
||
<CardHeader className="pb-3 px-8 pt-8">
|
||
<CardDescription className="uppercase text-[10px] font-bold tracking-[0.2em] text-[#94a3b8]">失败次数</CardDescription>
|
||
<CardTitle className="text-4xl font-bold text-red-500 font-mono">
|
||
{testCases.reduce((acc, c) => acc + c.results.filter(r => r.error).length, 0)}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
</Card>
|
||
</div>
|
||
|
||
<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">
|
||
<Button
|
||
onClick={exportToExcel}
|
||
size="sm"
|
||
className="h-10 px-6 gap-2 bg-[#0f172a] hover:bg-[#1e293b] rounded-full shadow-lg shadow-slate-200 text-xs font-bold transition-all active:scale-[0.98]"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
导出 EXCEL 报告
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="max-h-[600px] overflow-auto">
|
||
<ResultsTable flatResults={getFlatResults()} inputs={inputs} appMode={difyConfig.appMode} showTrace={anyTrace} />
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="flex flex-wrap justify-center gap-4 pt-4 pb-12">
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 px-10 rounded-full border-slate-200 text-[#0f172a] font-bold hover:bg-slate-50 transition-all active:scale-95"
|
||
onClick={() => setStep(2)}
|
||
>
|
||
返回编辑用例
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 px-10 rounded-full border-slate-200 text-[#0f172a] font-bold hover:bg-slate-50 transition-all active:scale-95"
|
||
onClick={() => {
|
||
setStep(1);
|
||
setInputs([]);
|
||
setTestCases([]);
|
||
}}
|
||
>
|
||
清空并重新开始
|
||
</Button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
{/* Removed Footer Decoration for Sleek Theme */}
|
||
</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;
|
||
}
|