高级编排分组

This commit is contained in:
zhangkai
2024-06-24 15:28:24 +08:00
parent 1c07d4b9df
commit 68c9acc85b
18 changed files with 1353 additions and 126 deletions

View File

@@ -0,0 +1,93 @@
import Skeleton from "@/components/bs-ui/skeleton";
import { CodeBlock } from "@/modals/formModal/chatMessage/codeBlock";
import { useDiffFlowStore } from "@/store/diffFlowStore";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeMathjax from "rehype-mathjax";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
const Cell = forwardRef((props, ref) => {
const [value, setValue] = useState('')
const [loading, setLoading] = useState(false)
useImperativeHandle(ref, () => ({
loading: () => {
setLoading(true)
},
loaded: () => {
setLoading(false)
},
setData: (val) => {
setLoading(false)
let i = 0
const print = () => {
const value = val.substring(0, i++)
setValue(value)
i < val.length && setTimeout(print, Math.floor(Math.random() * 10) + 20)
}
print()
},
getData() {
return value
}
}));
if (loading) return <Skeleton className="h-4 w-[200px]" />
return <div>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax]}
linkTarget="_blank"
className="bs-mkdown inline-block break-all max-w-full text-sm text-[#111]"
components={{
code: ({ node, inline, className, children, ...props }) => {
if (children.length) {
if (children[0] === "▍") {
return (<span className="form-modal-markdown-span"> </span>);
}
children[0] = (children[0] as string).replace("`▍`", "▍");
}
const match = /language-(\w+)/.exec(className || "");
return !inline ? (
<CodeBlock
key={Math.random()}
language={(match && match[1]) || ""}
value={String(children).replace(/\n$/, "")}
{...props}
/>
) : (
<code className={className} {...props}> {children} </code>
);
},
}}
>
{value.toString()}
</ReactMarkdown>
</div>
})
export default function CellWarp({ qIndex, versionId }) {
const ref = useRef(null);
const addCellRef = useDiffFlowStore(state => state.addCellRef);
const removeCellRef = useDiffFlowStore(state => state.removeCellRef);
useEffect(() => {
const key = `${qIndex}-${versionId}`
addCellRef(key, ref);
// 组件卸载时删除 ref
return () => {
removeCellRef(key);
};
}, [qIndex, versionId, addCellRef, removeCellRef]);
return <Cell ref={ref} />
};

View File

@@ -0,0 +1,122 @@
import { DelIcon } from "@/components/bs-icons/del";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/bs-ui/select";
import { useMemo } from "react";
import ComponentParameter from "./ComponentParameter";
import del from "../../../assets/npc/del.png";
export default function Component({ compId, options, disables, version, className, onChangeVersion, onClose }) {
// 保留当前compId和上游组件
const nodes = useMemo(() => {
if (!version?.data) return [];
const showNodes = {}
const edges = version.data.edges
const deep = (_compId) => {
edges.forEach(edge => {
if (edge.target === _compId) {
showNodes[edge.source] = true
showNodes[edge.target] = true
deep(edge.source)
}
})
}
deep(compId)
return version.data.nodes.filter(node => showNodes[node.id])
}, [version, compId])
// empty
if (!version) return <div className="bg-[#000000] rounded-md p-2 shadow-sm">
<div className="group flex justify-center items-center pb-2 border-b relative">
<Select onValueChange={onChangeVersion}>
<SelectTrigger className="w-[120px] h-6">
<SelectValue placeholder="选择版本" />
</SelectTrigger>
<SelectContent>
{
options.map(vs => (
<SelectItem key={vs.id} value={vs.id} textValue={'vs.name'} disabled={disables.includes(vs.id)}>
<div className="flex justify-between w-64">
<span className="w-36 overflow-hidden text-ellipsis whitespace-nowrap">{vs.name}</span>
<span className="text-xs text-muted-foreground">{vs.update_time.replace('T', ' ').substring(0, 16)}</span>
</div>
</SelectItem>
))
}
</SelectContent>
</Select>
{/* <DelIcon
className="absolute right-0 -top-1 cursor-pointer text-muted-foreground hidden group-hover:block"
onClick={onClose}
/> */}
<img src={del} alt="" className="absolute w-[14px] right-[2px] top-[2px] cursor-pointer text-muted-foreground hidden group-hover:block" onClick={onClose}/>
</div>
<div className="min-h-[100px]"></div>
</div>
// 版本信息
return <div className={'bg-[#000000] rounded-md p-2 shadow-sm ' + className}>
<div className="group flex justify-between items-center pb-2 border-b">
<Select value={version.id} onValueChange={onChangeVersion}>
<SelectTrigger className="w-[120px] h-6">
<SelectValue placeholder="选择版本" />
</SelectTrigger>
<SelectContent>
{
options.map(vs => (
<SelectItem key={vs.id} value={vs.id} textValue={'vs.name'} disabled={disables.includes(vs.id)}>
<div className="flex justify-between w-64">
<span className="w-36 overflow-hidden text-ellipsis whitespace-nowrap text-left">{vs.name}</span>
<span className="text-xs text-muted-foreground">{vs.update_time.replace('T', ' ').substring(0, 16)}</span>
</div>
</SelectItem>
))
}
</SelectContent>
</Select>
<span className="text-sm text-[#999999] relative pr-8">
{version.update_time.replace('T', ' ')}
{/* <DelIcon
className="absolute right-0 -top-1 cursor-pointer text-muted-foreground hidden group-hover:block"
onClick={onClose}
/> */}
<img src={del} alt="" className="absolute w-[14px] right-[2px] top-[2px] cursor-pointer text-muted-foreground hidden group-hover:block" onClick={onClose}/>
</span>
</div>
<div className="max-h-52 overflow-y-auto pb-10">
<div className="flex gap-1 px-2 py-1 text-sm text-muted-foreground">
<span className="min-w-12 w-28 text-[#999999]"></span>
<span className="min-w-12 w-28 text-[#999999]"></span>
<span className="flex-1 text-[#999999]"></span>
</div>
{
nodes.map(node => (
<div className="flex odd:bg-[#2B2B2B] bg-[#1A1A1A] gap-1 mt-1 px-2 py-1 text-sm rounded-sm">
<span className="min-w-12 w-28 break-all self-center text-[#FFFFFF]">{node.data.type}</span>
<div className="flex-1 min-w-0 pointer-events-none opacity-60">
{
<ComponentParameter
disabled
flow={version}
node={node}
template={node.data.node.template}
>
{
(key, name, formItem) => (
<div key={key} className="flex mb-1">
<span className="min-w-12 w-28 break-all text-[#999999]">{name}</span>
<div className="flex-1 min-w-0 text-[#999999]">{formItem}</div>
</div>
)
}
</ComponentParameter>
}
</div>
</div>
))
}
</div>
</div>
};

View File

@@ -0,0 +1,176 @@
import CodeAreaComponent from "@/components/codeAreaComponent";
import Dropdown from "@/components/dropdownComponent";
import FloatComponent from "@/components/floatComponent";
import InputComponent from "@/components/inputComponent";
import InputFileComponent from "@/components/inputFileComponent";
import InputListComponent from "@/components/inputListComponent";
import IntComponent from "@/components/intComponent";
import PromptAreaComponent from "@/components/promptComponent";
import TextAreaComponent from "@/components/textAreaComponent";
import ToggleShadComponent from "@/components/toggleShadComponent";
import { useMemo } from "react";
/**
* 组件中的填写参数罗列
* 参数模板 template
*/
export default function ComponentParameter({ disabled = false, flow, node, template, children, onChange = () => { } }) {
const _disabled = false // disabled || (flow.data.edges.some((e) => e.targetHandle === node.id) ?? false);
const keys = useMemo(() => {
return Object.keys(template).filter(
(t) =>
t.charAt(0) !== "_" &&
template[t].show &&
(template[t].type === "str" ||
template[t].type === "bool" ||
template[t].type === "float" ||
template[t].type === "code" ||
template[t].type === "prompt" ||
template[t].type === "file" ||
template[t].type === "int" ||
template[t].type === "dict")
)
}, [template])
const handleOnNewValue = (newValue: any, name) => {
// console.log('object :>> ', object);
// 引用更新
node.data.node.template[name].value = newValue;
// 手动修改知识库collection_id 清空
if (['index_name', 'collection_name'].includes(name)) delete node.data.node.template[name].collection_id
onChange() // 更新通知
}
const getStrComp = (template, n) => {
return template[n].list ? (
<InputListComponent
editNode={true}
disabled={_disabled}
value={
!template[n].value ||
template[n].value === ""
? [""]
: template[n].value
}
onChange={(t: string[]) => {
handleOnNewValue(t, n);
}}
/>
) : template[n].multiline ? (
<TextAreaComponent
disabled={_disabled}
editNode={true}
value={template[n].value ?? ""}
onChange={(t: string) => {
handleOnNewValue(t, n);
}}
/>
) : (
<InputComponent
editNode={true}
disabled={_disabled}
password={
template[n].password ?? false
}
value={template[n].value ?? ""}
onChange={(t) => {
handleOnNewValue(t, n);
}}
/>
)
}
return <>
{keys.map((n, i) => {
const name = template[n].name || template[n].display_name
if (template[n].type === "str") {
if (template[n].options) {
return children(n, name, <Dropdown
numberOfOptions={keys.length}
editNode={true}
options={template[n].options}
onSelect={(t) => handleOnNewValue(t, n)}
value={
template[n].value ??
"Choose an option"
}
></Dropdown>)
} else {
return children(n, name, getStrComp(template, n))
}
}
switch (template[n].type) {
case "bool":
return children(n, name, <ToggleShadComponent
disabled={_disabled}
enabled={template[n].value}
setEnabled={(t) => {
handleOnNewValue(t, n);
}}
size="small"
/>)
case "float":
return children(n, name, <FloatComponent
disabled={_disabled}
editNode={true}
value={template[n].value ?? ""}
onChange={(t) => {
template[n].value = t;
}}
/>)
case "int":
return children(n, name, <IntComponent
disabled={_disabled}
editNode={true}
value={template[n].value ?? ""}
onChange={(t) => {
handleOnNewValue(t, n);
}}
/>)
case "file":
return children(n, name, <InputFileComponent
editNode={true}
disabled={_disabled}
value={template[n].value ?? ""}
onChange={(t: string) => {
handleOnNewValue(t, n);
}}
fileTypes={template[n].fileTypes}
suffixes={template[n].suffixes}
onFileChange={(t: string) => {
handleOnNewValue(t, n);
}}
></InputFileComponent>)
case "prompt":
return children(n, name, <PromptAreaComponent
field_name={n}
editNode={true}
disabled={_disabled}
nodeClass={node.data.node}
setNodeClass={(nodeClass) => {
node.data.node = nodeClass;
}}
value={template[n].value ?? ""}
onChange={(t: string) => {
handleOnNewValue(t, n);
}}
/>)
case "code":
return children(n, name, <CodeAreaComponent
disabled={_disabled}
editNode={true}
value={template[n].value ?? ""}
onChange={(t: string) => {
handleOnNewValue(t, n);
}}
/>)
case "Any": return children(n, name, "-")
default: return children(n, name, <div className="hidden"></div>)
}
})
}
</>
};

View File

@@ -0,0 +1,28 @@
import { Button } from "@/components/bs-ui/button";
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
import ChatReportForm from "@/pages/ChatAppPage/components/ChatReportForm";
import { useRef } from "react";
export default function RunForm({ show, flow, onChangeShow, onSubmit }) {
const formRef = useRef(null);
const handleSubmit = () => {
formRef.current.submit()
}
return <DialogContent className="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{
show && <ChatReportForm ref={formRef} type='diff' vid={flow.id} flow={flow} onStart={onSubmit} />
}
<DialogFooter>
<DialogClose>
<Button variant="outline" className="px-11" type="button" onClick={onChangeShow}></Button>
</DialogClose>
<Button type="submit" className="px-11" onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
};

View File

@@ -0,0 +1,345 @@
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="npcInput1"
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="npcInput1"
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
}