345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
import { Button } from "@/components/bs-ui/button";
|
||
import { Dialog, DialogTrigger } from "@/components/bs-ui/dialog";
|
||
import { Input } from "@/components/bs-ui/input";
|
||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table";
|
||
import { useToast } from "@/components/bs-ui/toast/use-toast";
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/bs-ui/tooltip";
|
||
import { useDiffFlowStore } from "@/store/diffFlowStore";
|
||
import { DownloadIcon, PlayIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons";
|
||
import { useMemo, useRef, useState } from "react";
|
||
import CellWarp from "./Cell";
|
||
import RunForm from "./RunForm";
|
||
import { DelIcon } from "@/components/bs-icons/del";
|
||
import * as XLSX from 'xlsx';
|
||
import { useTranslation } from "react-i18next";
|
||
import { FlowStyleType, FlowType } from "@/types/flow";
|
||
import { postBuildInit } from "@/controllers/API";
|
||
import { generateUUID } from "@/components/bs-ui/utils";
|
||
import del from "../../../assets/npc/del.png";
|
||
|
||
export default function RunTest({ nodeId }) {
|
||
|
||
const { t } = useTranslation()
|
||
const [formShow, setFormShow] = useState(false)
|
||
const { running, runningType, mulitVersionFlow, readyVersions, questions, removeQuestion, cellRefs,
|
||
allRunStart, rowRunStart, colRunStart, overQuestions, addQuestion, updateQuestion } = useDiffFlowStore()
|
||
|
||
// 是否展示表单
|
||
const isForm = useMemo(() => {
|
||
const flowData = mulitVersionFlow?.[0]?.data
|
||
if (!flowData) return false
|
||
|
||
return flowData.nodes.some(node => ["VariableNode", "InputFileNode"].includes(node.data.type))
|
||
}, [mulitVersionFlow])
|
||
|
||
// 选中的测试版本数
|
||
const versionColWidth = useMemo(() => {
|
||
const count = mulitVersionFlow.reduce((count, cur) => {
|
||
return cur ? count + 1 : count
|
||
}, 0) + 1 // +1 测试用例列
|
||
|
||
return 100 / (count === 2 ? 2 : count + 1) // hack 两个 按 45% 分
|
||
}, [mulitVersionFlow])
|
||
|
||
const handleUploadTxt = () => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = ".txt";
|
||
input.onchange = (e: Event) => {
|
||
if (
|
||
(e.target as HTMLInputElement).files[0].type === "text/plain"
|
||
) {
|
||
const currentfile = (e.target as HTMLInputElement).files[0];
|
||
currentfile.text().then((text) => {
|
||
console.log(text, "text");
|
||
overQuestions(text.split('\n'))
|
||
});
|
||
}
|
||
};
|
||
input.click();
|
||
}
|
||
|
||
const { message } = useToast()
|
||
const inputsRef = useRef(null)
|
||
const build = useBuild()
|
||
const handleRunTest = async (inputs = null, query = '') => {
|
||
setFormShow(false)
|
||
const res = await build(mulitVersionFlow[0])
|
||
// console.log('res :>> ', res);
|
||
const input = res.input_keys.find((el: any) => !el.type)
|
||
const inputKey = input ? Object.keys(input)[0] : '';
|
||
inputsRef.current = { ...input, id: nodeId, [inputKey]: query, data: inputs }
|
||
//
|
||
if (questions.length === 0) return message({
|
||
title: t('prompt'),
|
||
description: t('test.addTest'),
|
||
variant: 'warning'
|
||
})
|
||
allRunStart(nodeId, inputsRef.current)
|
||
}
|
||
|
||
const handleColRunTest = (versionId) => {
|
||
colRunStart(versionId, nodeId, inputsRef.current)
|
||
}
|
||
|
||
const handleRowRunTest = (qIndex) => {
|
||
rowRunStart(qIndex, nodeId, inputsRef.current)
|
||
}
|
||
|
||
// 导出结果(excle)
|
||
const handleDownExcle = () => {
|
||
const data = [['测试用例', ...mulitVersionFlow.map(version => version.name)]];
|
||
|
||
questions.forEach((_, index) => {
|
||
const rowData = [_.q]
|
||
mulitVersionFlow.forEach(version => {
|
||
rowData.push(cellRefs[`${index}-${version.id}`].current.getData())
|
||
})
|
||
data.push(rowData)
|
||
})
|
||
mulitVersionFlow
|
||
|
||
// 创建Workbook对象
|
||
const wb = XLSX.utils.book_new();
|
||
// 添加Worksheet到Workbook中
|
||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||
XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
|
||
|
||
// 生成Excel文件
|
||
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||
const blob = new Blob([wbout], { type: 'application/octet-stream' });
|
||
// 创建下载链接
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "test_result.xlsx";
|
||
|
||
// 模拟点击下载链接
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
|
||
// 清理URL对象
|
||
setTimeout(function () {
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
}, 0);
|
||
}
|
||
|
||
const notDiffVersion = useMemo(() => !mulitVersionFlow.some((version) => version), [mulitVersionFlow])
|
||
|
||
return <div className="mt-4 px-4">
|
||
<div className="bg-[#000000] p-2">
|
||
<div className="flex items-center justify-between ">
|
||
<div className="flex gap-2 items-center">
|
||
<Button size="sm" disabled={['all', 'row', 'col'].includes(runningType)} className="baogao-btn2 border-radius-14 ml-0" onClick={handleUploadTxt}>{t('test.uploadTest')}</Button>
|
||
<TooltipProvider delayDuration={200}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<QuestionMarkCircledIcon />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>{t('test.explain')}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
{
|
||
isForm ? <Dialog open={formShow} onOpenChange={setFormShow}>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm" className="baogao-btn2 border-radius-14 ml-0" disabled={runningType === 'all' || notDiffVersion}><PlayIcon />{t('test.testRun')}</Button>
|
||
</DialogTrigger>
|
||
<RunForm show={formShow} flow={mulitVersionFlow[0]} onChangeShow={setFormShow} onSubmit={handleRunTest} />
|
||
</Dialog> :
|
||
<Button size="sm" className="baogao-btn2 border-radius-14 ml-0" disabled={runningType === 'all' || notDiffVersion} onClick={() => handleRunTest()}><PlayIcon />{t('test.testRun')}</Button>
|
||
}
|
||
</div>
|
||
{/* table */}
|
||
<Table className="table-fixed">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-[#999999]" style={{ width: `${versionColWidth}%` }}>{t('test.testCase')}</TableHead>
|
||
{
|
||
mulitVersionFlow.map(version =>
|
||
version && <TableHead className="text-[#999999]" key={version.id} style={{ width: `${versionColWidth + 10}%` }}>
|
||
<div className="flex items-center gap-2">
|
||
<span>{version.name}</span>
|
||
{readyVersions[version.id] && <Button
|
||
disabled={['all'].includes(runningType)}
|
||
size='icon'
|
||
className="w-6 h-6"
|
||
title={t('test.run')}
|
||
onClick={() => handleColRunTest(version.id)}
|
||
><PlayIcon /></Button>}
|
||
</div>
|
||
</TableHead>
|
||
)
|
||
}
|
||
<TableHead className="text-right min-w-[135px] text-[#FFD025]" style={{ width: 135 }}>
|
||
<Button variant="link" className="text-[#FFD025] disabled:opacity-1" disabled={runningType !== '' || !running} onClick={handleDownExcle}><DownloadIcon className="mr-1" />{t('test.downloadResults')}</Button>
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{
|
||
questions.map((question, index) => (
|
||
<TableRow>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2 font-medium">
|
||
<Input
|
||
className="npcInput4"
|
||
disabled={['all', 'row'].includes(runningType)}
|
||
placeholder={t('test.testCases')}
|
||
value={question.q}
|
||
onChange={(e) => updateQuestion(e.target.value, index)}
|
||
></Input>
|
||
{question.ready && <Button
|
||
disabled={['all'].includes(runningType) || notDiffVersion}
|
||
size='icon'
|
||
className="min-w-6 h-6 bg-[#FFD025]"
|
||
title="运行"
|
||
onClick={() => handleRowRunTest(index)}
|
||
><PlayIcon /></Button>}
|
||
</div>
|
||
</TableCell>
|
||
{/* 版本 */}
|
||
{mulitVersionFlow.map(flow =>
|
||
flow && <TableCell key={index + '-' + flow.id} className=''>
|
||
<CellWarp qIndex={index} versionId={flow.id} />
|
||
</TableCell>
|
||
)}
|
||
<TableCell className="text-right">
|
||
<Button
|
||
size="icon"
|
||
variant="link"
|
||
disabled={['all', 'row'].includes(runningType)}
|
||
onClick={() => removeQuestion(index)}>
|
||
{/* <DelIcon /> */}
|
||
<img src={del} alt="" className="w-[14px]" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
}
|
||
</TableBody>
|
||
<TableFooter>
|
||
<TableRow>
|
||
{questions.length < 20 && <TableCell>
|
||
<div className="flex items-center gap-2 font-medium min-w-52">
|
||
<Input
|
||
className="npcInput4"
|
||
placeholder={t('test.testCases')}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
if (!e.target.value) return
|
||
addQuestion(e.target.value)
|
||
e.target.value = ''
|
||
}
|
||
}}
|
||
onBlur={(e) => {
|
||
if (!e.target.value) return
|
||
addQuestion(e.target.value)
|
||
e.target.value = ''
|
||
}} />
|
||
</div>
|
||
</TableCell>
|
||
}
|
||
<TableCell colSpan={5} className="text-right"></TableCell>
|
||
</TableRow>
|
||
</TableFooter>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
};
|
||
|
||
|
||
const useBuild = () => {
|
||
const { toast } = useToast()
|
||
|
||
// SSE 服务端推送
|
||
async function streamNodeData(flow: FlowType, chatId: string) {
|
||
let res = null
|
||
// Step 1: Make a POST request to send the flow data and receive a unique session ID
|
||
const _flow = { ...flow, id: flow.flow_id }
|
||
const { flowId } = await postBuildInit({ flow: _flow, versionId: flow.id });
|
||
// Step 2: Use the session ID to establish an SSE connection using EventSource
|
||
let validationResults = [];
|
||
let finished = false;
|
||
let buildEnd = false
|
||
const qstr = flow.id ? `?version_id=${flow.id}` : ''
|
||
const apiUrl = `/api/v1/build/stream/${flowId}${qstr}`;
|
||
const eventSource = new EventSource(apiUrl);
|
||
|
||
eventSource.onmessage = (event) => {
|
||
// If the event is parseable, return
|
||
if (!event.data) {
|
||
return;
|
||
}
|
||
const parsedData = JSON.parse(event.data);
|
||
// if the event is the end of the stream, close the connection
|
||
if (parsedData.end_of_stream) {
|
||
eventSource.close(); // 结束关闭链接
|
||
buildEnd = true
|
||
return;
|
||
} else if (parsedData.log) {
|
||
// If the event is a log, log it
|
||
// setSuccessData({ title: parsedData.log });
|
||
} else if (parsedData.input_keys) {
|
||
res = parsedData
|
||
} else {
|
||
// setProgress(parsedData.progress);
|
||
validationResults.push(parsedData.valid);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = (error: any) => {
|
||
console.error("EventSource failed:", error);
|
||
eventSource.close();
|
||
if (error.data) {
|
||
const parsedData = JSON.parse(error.data);
|
||
toast({
|
||
title: parsedData.error,
|
||
variant: 'error',
|
||
description: ''
|
||
});
|
||
}
|
||
};
|
||
// Step 3: Wait for the stream to finish
|
||
while (!finished) {
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
finished = buildEnd // validationResults.length === flow.data.nodes.length;
|
||
}
|
||
// Step 4: Return true if all nodes are valid, false otherwise
|
||
if (validationResults.every((result) => result)) {
|
||
return res
|
||
}
|
||
}
|
||
|
||
// 延时器
|
||
async function enforceMinimumLoadingTime(
|
||
startTime: number,
|
||
minimumLoadingTime: number
|
||
) {
|
||
const elapsedTime = Date.now() - startTime;
|
||
const remainingTime = minimumLoadingTime - elapsedTime;
|
||
|
||
if (remainingTime > 0) {
|
||
return new Promise((resolve) => setTimeout(resolve, remainingTime));
|
||
}
|
||
}
|
||
|
||
async function handleBuild(flow: FlowStyleType) {
|
||
try {
|
||
const minimumLoadingTime = 200; // in milliseconds
|
||
const startTime = Date.now();
|
||
|
||
const res = await streamNodeData(flow, generateUUID(32));
|
||
await enforceMinimumLoadingTime(startTime, minimumLoadingTime); // 至少等200ms, 再继续(强制最小load时间)
|
||
return res
|
||
} catch (error) {
|
||
console.error("Error:", error);
|
||
} finally {
|
||
}
|
||
}
|
||
|
||
return handleBuild
|
||
} |