Przeglądaj źródła

高级编排分组

zhangkai 1 rok temu
rodzic
commit
68c9acc85b

+ 6 - 4
src/CustomNodes/GenericNode/index.tsx

@@ -171,12 +171,14 @@ export default function GenericNode({ data, xPos, yPos, selected }: {
         </div >
 
 
-        <div className="generic-node-desc nodrag" onKeyDown={e => e.stopPropagation()}>
-          <div className="generic-node-desc-text" onClick={() => data.node.description_url && openPopUp(<DescriptionModel data={data.node.description_url} />)}>{data.node.description}</div>
+        {/* <div className="generic-node-desc nodrag" onKeyDown={e => e.stopPropagation()}>
+          <div className="generic-node-desc-text" onClick={() => data.node.description_url && openPopUp(<DescriptionModel data={data.node.description_url} />)}>{data.node.description}</div> */}
 {/*=======*/}
 {/*        <div className="generic-node-desc nodrag">*/}
 {/*          <div className="generic-node-desc-text">{data.node.description}</div>*/}
 {/*>>>>>>> bisheng_github*/}
+        <div className="generic-node-desc nodrag">
+            <div className="generic-node-desc-text">{data.node.description}</div>
           <>
             {Object.keys(data.node.template)
               .filter((t) => t.charAt(0) !== "_")
@@ -230,7 +232,7 @@ export default function GenericNode({ data, xPos, yPos, selected }: {
                       type={data.node.template[t].type}
                       optionalHandle={data.node.template[t].input_types}
                       onChange={() => fouceUpdateNode(!_)}
-                      nodeColorsP={nodeColors}
+                      // nodeColorsP={nodeColors}
                     />
                   ) : (
                     <></>
@@ -258,7 +260,7 @@ export default function GenericNode({ data, xPos, yPos, selected }: {
               id={[data.type, data.id, ...data.node.base_classes].join("|")}
               type={data.node.base_classes.join("|")}
               left={false}
-              nodeColorsP={nodeColors}
+              // nodeColorsP={nodeColors}
             />
             {data.type === 'Report' && <div className="w-full bg-muted px-5 py-2">
               <Link to={`/report/${flowId}`}><Button variant="outline" className="px-10">Edit</Button></Link>

BIN
src/assets/toolbar/version.png


+ 8 - 8
src/components/chatComponent/buildTrigger/index.tsx

@@ -52,13 +52,13 @@ export default function BuildTrigger({
       /**
        * 拦截flow,过滤node数据,去除groupNode
        */
-      try {
-        flow.data.nodes = flow?.data?.nodes?.filter(node => {
-          return node.id.indexOf('groupNode') < 0;
-        })
-      } catch (e) {
-        console.log(e)
-      }
+      // try {
+      //   flow.data.nodes = flow?.data?.nodes?.filter(node => {
+      //     return node.id.indexOf('groupNode') < 0;
+      //   })
+      // } catch (e) {
+      //   console.log(e)
+      // }
 
       const allNodesValid = await streamNodeData(flow);
       await enforceMinimumLoadingTime(startTime, minimumLoadingTime); // 200内完成streamNodeData,阻塞剩余时间;否则不阻塞(最大等待200)
@@ -79,7 +79,7 @@ export default function BuildTrigger({
   }
   async function streamNodeData(flow: FlowType) {
     // Step 1: Make a POST request to send the flow data and receive a unique session ID
-    const { flowId } = await postBuildInit(flow);
+    const { flowId } = await postBuildInit({ flow });
     // Step 2: Use the session ID to establish an SSE connection using EventSource
     let validationResults = [];
     let finished = false;

+ 15 - 2
src/contexts/tabsContext.tsx

@@ -3,7 +3,7 @@ import { ReactNode, createContext, useContext, useState } from "react";
 import { addEdge } from "reactflow";
 import { updateFlowApi } from "../controllers/API/flow";
 import { APIClassType, APITemplateType } from "../types/api";
-import { FlowType, NodeType } from "../types/flow";
+import { FlowType, FlowVersionItem, NodeType } from "../types/flow";
 import { TabsContextType, TabsState } from "../types/tabs";
 import { generateUUID, updateTemplate } from "../utils";
 import { alertContext } from "./alertContext";
@@ -28,6 +28,8 @@ const TabsContextInitialValue: TabsContextType = {
     selection: { nodes: any; edges: any },
     position: { x: number; y: number; paneX?: number; paneY?: number }
   ) => { },
+  version: null,
+  setVersion: (version: FlowVersionItem | null) => ""
 };
 
 export const TabsContext = createContext<TabsContextType>(
@@ -36,6 +38,7 @@ export const TabsContext = createContext<TabsContextType>(
 
 export function TabsProvider({ children }: { children: ReactNode }) {
   const [flow, setFlow] = useState<FlowType>(null);
+  const [version, setVersion] = useState<FlowVersionItem | null>(null);
   // flowid: formKeysData
   const [tabsState, setTabsState] = useState<TabsState>({});
   const [lastCopiedSelection, setLastCopiedSelection] = useState(null);
@@ -289,6 +292,12 @@ export function TabsProvider({ children }: { children: ReactNode }) {
     });
   }
 
+  // 上线版本的版本 id
+  const [onlineVid, setOnlineVid] = useState(0);
+  const updateOnlineVid = (vid: number) => {
+    setOnlineVid(flow.status === 2 ? vid : 0);
+  }
+
   return (
     <TabsContext.Provider
       value={{
@@ -313,7 +322,11 @@ export function TabsProvider({ children }: { children: ReactNode }) {
         getNodeId,
         tabsState,
         setTabsState,
-        paste
+        paste,
+        version,
+        setVersion,
+        isOnlineVersion: () => version.id === onlineVid,
+        updateOnlineVid
       }}
     >
       {children}

+ 93 - 0
src/pages/DiffFlowPage/components/Cell.tsx

@@ -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} />
+};

+ 122 - 0
src/pages/DiffFlowPage/components/Component.tsx

@@ -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>
+};

+ 176 - 0
src/pages/DiffFlowPage/components/ComponentParameter.tsx

@@ -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>)
+            }
+        })
+        }
+    </>
+};

+ 28 - 0
src/pages/DiffFlowPage/components/RunForm.tsx

@@ -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>
+};

+ 345 - 0
src/pages/DiffFlowPage/components/RunTest.tsx

@@ -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
+}

+ 86 - 0
src/pages/DiffFlowPage/index.tsx

@@ -0,0 +1,86 @@
+import { PlusIcon } from "@/components/bs-icons/plus"
+import { Button } from "@/components/bs-ui/button"
+import { ChevronLeftIcon } from "@radix-ui/react-icons"
+import { useNavigate, useParams } from "react-router-dom"
+import Component from "./components/Component"
+import RunTest from "./components/RunTest"
+import { useDiffFlowStore } from "@/store/diffFlowStore"
+import { useEffect, useState } from "react"
+import { useToast } from "@/components/bs-ui/toast/use-toast"
+import { getFlowVersions } from "@/controllers/API/flow"
+import { FlowVersionItem } from "@/types/flow"
+import gongjuAdd from "../../assets/npc/gongjuAdd.png";
+
+export default function index(params) {
+    // 技能 id, 版本id, 组件id
+    const { id, vid, cid } = useParams()
+    const navigate = useNavigate()
+    const { message } = useToast()
+
+    const versions = useVersions(id)
+
+    const { mulitVersionFlow, removeVersionFlow, initFristVersionFlow, addEmptyVersionFlow, addVersionFlow } = useDiffFlowStore()
+    useEffect(() => {
+        initFristVersionFlow(vid)
+    }, [])
+
+    const handleAddVersion = () => {
+        if (mulitVersionFlow.length >= 4) return message({
+            title: '',
+            description: '最多添加4个版本',
+            variant: 'error',
+        })
+        addEmptyVersionFlow()
+    }
+
+    console.log('mulitVersionFlow', mulitVersionFlow);
+
+
+    return <div className="bg-[#1A1A1A] h-full relative">
+        {/* header */}
+        <div className="absolute top-0 w-full h-14 flex justify-between items-center border-b px-4 bg-[#000000]">
+            <Button variant="outline" size="icon" onClick={() => navigate(-1)}><ChevronLeftIcon className="h-4 w-4" /></Button>
+            <span className="text-[#FFFFFF]">版本评估</span>
+            <Button type="button" className="baogao-btn2 border-radius-14" onClick={handleAddVersion}>
+                {/* <PlusIcon className="text-primary" /> */}
+                <img src={gongjuAdd} alt="" className="w-[14px] mr-[7px]" />
+                添加版本({mulitVersionFlow.length}/4)
+            </Button>
+        </div>
+
+        {/* content */}
+        <div className="h-full pt-14 overflow-y-auto">
+            {/* comps */}
+            <div className={`grid gap-4 mt-4 px-4 box-border ${mulitVersionFlow.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}>
+                {
+                    mulitVersionFlow.map((version, index) => (
+                        <Component
+                            key={index}
+                            compId={cid}
+                            options={versions}
+                            disables={mulitVersionFlow.map((v) => v?.id)}
+                            version={version}
+                            className={''}
+                            onChangeVersion={(vid) => addVersionFlow(vid, index)}
+                            onClose={() => removeVersionFlow(index)}
+                        />
+                    ))
+                }
+            </div>
+            {/* run test */}
+            <RunTest nodeId={cid}></RunTest>
+        </div>
+    </div>
+};
+
+
+const useVersions = (flowId) => {
+    const [versions, setVersions] = useState<FlowVersionItem[]>([])
+    useEffect(() => {
+        getFlowVersions(flowId).then(({ data }) => {
+            setVersions(data)
+        })
+    }, [])
+
+    return versions
+}

+ 288 - 0
src/pages/FlowPage/components/Header.tsx

@@ -0,0 +1,288 @@
+import AlertDropdown from "@/alerts/alertDropDown";
+import { DelIcon } from "@/components/bs-icons/del";
+import { LoadIcon } from "@/components/bs-icons/loading";
+import { SaveIcon } from "@/components/bs-icons/save";
+import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm";
+import { Button } from "@/components/bs-ui/button";
+import ActionButton from "@/components/bs-ui/button/actionButton";
+import TextInput from "@/components/bs-ui/input/textInput";
+import { RadioGroup, RadioGroupItem } from "@/components/bs-ui/radio";
+import { useToast } from "@/components/bs-ui/toast/use-toast";
+import { alertContext } from "@/contexts/alertContext";
+import { PopUpContext } from "@/contexts/popUpContext";
+import { TabsContext } from "@/contexts/tabsContext";
+import { typesContext } from "@/contexts/typesContext";
+import { undoRedoContext } from "@/contexts/undoRedoContext";
+import { createFlowVersion, deleteVersion, getFlowVersions, getVersionDetails, updateVersion } from "@/controllers/API/flow";
+import { captureAndAlertRequestErrorHoc } from "@/controllers/request";
+import ApiModal from "@/modals/ApiModal";
+import L2ParamsModal from "@/modals/L2ParamsModal";
+import ExportModal from "@/modals/exportModal";
+import { FlowVersionItem } from "@/types/flow";
+import { ArrowDownIcon, ArrowUpIcon, BellIcon, CodeIcon, ExitIcon, LayersIcon, StackIcon } from "@radix-ui/react-icons";
+import { t } from "i18next";
+import { useContext, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+// import TipPng from "../../../assets/tip.jpg";
+import jianhua from "../../../assets/npc/jianhua.png";
+import xiaoxi from "../../../assets/npc/xiaoxi.png";
+import tuichu from "../../../assets/npc/tuichu.png";
+import lingcun from "../../../assets/npc/lingcun.png";
+import rb_1 from "../../../assets/npc/rb-1.png";
+import rb_2 from "../../../assets/npc/rb-2.png";
+import rb_3 from "../../../assets/npc/rb-3.png";
+import rb_4 from "../../../assets/npc/rb-4.png";
+import rb_4_active from "../../../assets/npc/rb-4-active.png";
+
+export default function Header({ flow }) {
+    const navgate = useNavigate()
+    const { t } = useTranslation()
+    const { message } = useToast()
+    const [open, setOpen] = useState(false)
+    const AlertWidth = 384;
+    const { notificationCenter, setNotificationCenter, setSuccessData } = useContext(alertContext);
+    const { uploadFlow, setFlow, tabsState, saveFlow } = useContext(TabsContext);
+    const { reactFlowInstance } = useContext(typesContext);
+
+    const isPending = tabsState[flow.id]?.isPending;
+    console.log(isPending)
+    const { openPopUp } = useContext(PopUpContext);
+    // 记录快照
+    const { takeSnapshot } = useContext(undoRedoContext);
+
+    const handleSaveNewVersion = async () => {
+        // 累加版本 vx ++
+        const maxNo = lastVersionIndexRef.current + 1
+        // versions.forEach(v => {
+        //     const match = v.name.match(/[vV](\d+)/)
+        //     maxNo = match ? Math.max(Number(match[1]), maxNo) : maxNo
+        // })
+        // maxNo++
+        // save
+        const res = await captureAndAlertRequestErrorHoc(
+            createFlowVersion(flow.id, { name: `v${maxNo}`, description: '', data: flow.data, original_version_id: version.id })
+        )
+        message({
+            variant: "success",
+            title: `${t('skills.version')} v${maxNo} ${t('skills.saveSuccessful')}`,
+            description: ""
+        })
+        // 更新版本列表
+        await refrenshVersions()
+        // 切换到最新版本
+        
+        setVersionId(res.id)
+    }
+    // 
+    const [saveVersionId, setVersionId] = useState('')
+    useEffect(() => {
+        saveVersionId && handleChangeVersion(saveVersionId)
+    }, [saveVersionId])
+
+    // 版本管理
+    const [loading, setLoading] = useState(false)
+    const { versions, version, lastVersionIndexRef, changeName, deleteVersion, refrenshVersions, setCurrentVersion } = useVersion(flow)
+    // 切换版本
+    const handleChangeVersion = async (versionId) => {
+        setLoading(true)
+        reactFlowInstance.setNodes([]) // 便于重新渲染节点
+        // 保存当前版本
+        // updateVersion(version.id, { name: version.name, description: '', data: flow.data })
+        // 切换版本UI
+        setCurrentVersion(Number(versionId))
+        // 加载选中版本data
+        const res = await getVersionDetails(versionId)
+        // 自动触发 page的 clone flow
+        setFlow('versionChange', { ...flow, data: res.data })
+        message({
+            variant: "success",
+            title: `切换到 ${res.name}`,
+            description: ""
+        })
+        setLoading(false)
+    }
+    // 保存版本
+    const handleSaveVersion = async () => {
+        // 保存当前版本
+        captureAndAlertRequestErrorHoc(updateVersion(version.id, { name: version.name, description: '', data: flow.data }).then(_ => {
+            setFlow('versionChange', { ...flow }) // 更新clone flow,避免触发diff不同
+
+            _ && message({
+                variant: "success",
+                title: t('success'),
+                description: ""
+            })
+        }))
+    }
+
+    return <div className="">
+        {
+            loading && <div className=" fixed left-0 top-0 w-full h-screen bg-gray-50/60 z-50 flex items-center justify-center">
+                <LoadIcon className="mr-2 text-gray-600" />
+                <span>切换到 {version.name}</span>
+            </div>
+        }
+        {/* <div className="flex items-center gap-2 py-4">
+            <Button
+                variant="outline"
+                size="icon"
+                onClick={() => navgate('/build/skills', { replace: true })}
+            ><ExitIcon className="h-4 w-4 rotate-180" /></Button>
+            <Button variant="outline" onClick={() => { takeSnapshot(); uploadFlow() }} >
+                <ArrowUpIcon className="h-4 w-4 mr-1" />{t('skills.import')}
+            </Button>
+            <Button variant="outline" onClick={() => { openPopUp(<ExportModal />) }}>
+                <ArrowDownIcon className="h-4 w-4 mr-1" />{t('skills.export')}
+            </Button>
+            <Button variant="outline" onClick={() => { openPopUp(<ApiModal flow={flow} />) }} >
+                <CodeIcon className="h-4 w-4 mr-1" />{t('skills.code')}
+            </Button>
+            <Button variant="outline" onClick={() => setOpen(true)} >
+                <StackIcon className="h-4 w-4 mr-1" />{t('skills.simplify')}
+            </Button>
+        </div> */}
+        {
+            version && <div className="flex fixed right-[14px] top-[14px] z-10">
+                {/* <Button className="px-6 flex gap-2" type="button" onClick={handleSaveVersion}
+                    disabled={!isPending}><SaveIcon />{t('skills.save')}</Button> */}
+                <img src={jianhua} className="w-[27px] cursor-pointer" onClick={() => setOpen(true)} alt="" />
+                {isPending ? <img src={rb_4_active} onClick={handleSaveVersion} className="w-[27px] ml-[1px] mr-[1px] cursor-pointer" alt="" /> : <img src={rb_4} className="w-[27px] ml-[1px] mr-[1px] cursor-pointer" alt="" />}
+                <ActionButton
+                    className="px-6 flex gap-2 bg-[#1A1A1A] hover:bg-[#1A1A1A] border-[#1A1A1A]"
+                    align="end"
+                    variant="outline"
+                    onClick={handleSaveNewVersion}
+                    delayDuration={200}
+                    buttonTipContent={(
+                        <div>
+                            {/* <img src={TipPng} alt="" className="w-80" /> */}
+                            <p className="text-sm">{t('skills.supportVersions')}</p>
+                        </div>
+                    )}
+                    dropDown={(
+                        <div className=" overflow-y-auto max-h-96 max-h">
+                            <RadioGroup value={version.id + ''} onValueChange={(vid) => {
+                                updateVersion(version.id, { name: version.name, description: '', data: flow.data })
+                                handleChangeVersion(vid)
+                            }} className="gap-0">
+                                {versions.map((vers, index) => (
+                                    <div key={vers.id} className="group flex items-center gap-4 px-4 py-2 cursor-pointer hover:bg-gray-100 border-b">
+                                        <RadioGroupItem value={vers.id + ''} />
+                                        <div className="w-[198px]">
+                                            <TextInput
+                                                className="h-[30px]"
+                                                type="hover"
+                                                value={vers.name}
+                                                maxLength={30}
+                                                onSave={val => changeName(vers.id, val)}
+                                            ></TextInput>
+                                            <p className="text-sm text-muted-foreground mt-2">{vers.update_time.replace('T', ' ').substring(0, 16)}</p>
+                                        </div>
+                                        {
+                                            // 最后一个 V0 版本和当前选中版本不允许删除
+                                            !(version.id === vers.id)
+                                            && <Button
+                                                className="group-hover:flex hidden"
+                                                type="button"
+                                                size="icon"
+                                                variant="outline"
+                                                onClick={() => deleteVersion(vers, index)}
+                                            ><DelIcon /></Button>
+                                        }
+
+                                    </div>
+                                ))}
+                            </RadioGroup>
+                        </div>
+                    )}
+                ><img src={lingcun} className="w-[11px]" alt="" /><span className="text-[#999999]">{t('skills.saveVersion')}</span></ActionButton>
+                <div className="relative" onClick={(event: React.MouseEvent<HTMLElement>) => {
+                        setNotificationCenter(false);
+                        const { top, left } = (event.target as Element).getBoundingClientRect();
+                        openPopUp(
+                            <>
+                                <div className="absolute z-10" style={{ top: top + 40, left: left - AlertWidth }} ><AlertDropdown /></div>
+                                <div className="header-notifications-box"></div>
+                            </>
+                        );
+                    }}>
+                    <img src={xiaoxi} className="w-[27px] ml-[1px] cursor-pointer" alt="" />
+                    {notificationCenter && <div className="header-notifications top-[6px]"></div>}
+                </div>
+                <img src={tuichu} className="w-[27px] ml-[1px] cursor-pointer" onClick={(event) => navgate('/build/skills', { replace: true })} alt="" />
+            </div>
+        }
+        <div className="flex fixed right-[70px] bottom-[14px] z-10">
+            <img src={rb_1} className="w-[27px] cursor-pointer" onClick={() => { openPopUp(<ApiModal flow={flow} />); }} alt="" />
+            <img src={rb_2} className="w-[27px] ml-[1px] cursor-pointer" onClick={(event) => { openPopUp(<ExportModal />); }} alt="" />
+            <img src={rb_3} className="w-[27px] ml-[1px] cursor-pointer" onClick={(event) => { takeSnapshot(); uploadFlow() }} alt="" />
+        </div>
+        {/* 高级配置l2配置 */}
+        <L2ParamsModal data={flow} open={open} setOpen={setOpen} onSave={handleSaveVersion}></L2ParamsModal>
+    </div>
+};
+
+// 技能版本管理
+const useVersion = (flow) => {
+    const [versions, setVersions] = useState<FlowVersionItem[]>([])
+    const { version, setVersion, updateOnlineVid } = useContext(TabsContext)
+    const lastVersionIndexRef = useRef(0)
+
+    const refrenshVersions = () => {
+        return getFlowVersions(flow.id).then(({ data, total }) => {
+            setVersions(data)
+            lastVersionIndexRef.current = total - 1
+            const currentV = data.find(el => el.is_current === 1)
+            setVersion(currentV)
+            // 记录上线的版本
+            updateOnlineVid(currentV?.id)
+        })
+    }
+
+    useEffect(() => {
+        refrenshVersions()
+    }, [])
+
+    // 修改名字
+    const handleChangName = (id, name) => {
+        captureAndAlertRequestErrorHoc(updateVersion(id, { name, description: '', data: null }))
+        // 乐观更新
+        setVersions(versions.map(version => {
+            if (version.id === id) {
+                version.name = name;
+            }
+            return version;
+        }))
+    }
+
+    const handleDeleteVersion = (version, index) => {
+        bsConfirm({
+            title: t('prompt'),
+            desc: `${t('skills.deleteOrNot')} ${version.name} ${t('skills.version')}?`,
+            onOk: (next) => {
+                captureAndAlertRequestErrorHoc(deleteVersion(version.id)).then(res => {
+                    if (res === null) {
+                        // 乐观更新
+                        setVersions(versions.filter((_, i) => i !== index))
+                    }
+                })
+                next()
+            }
+        })
+    }
+
+    return {
+        versions,
+        version,
+        lastVersionIndexRef,
+        setCurrentVersion(versionId) {
+            const currentV = versions.find(el => el.id === versionId)
+            setVersion(currentV)
+            return currentV
+        },
+        refrenshVersions,
+        deleteVersion: handleDeleteVersion,
+        changeName: handleChangName,
+    }
+}

+ 104 - 87
src/pages/FlowPage/components/PageComponent/index.tsx

@@ -22,11 +22,11 @@ import ReactFlow, {
     useReactFlow,
 } from "reactflow";
 import GenericNode from "../../../../CustomNodes/GenericNode";
-import FrameSelectToolbar from "../FrameSelectToolbarComponent";
-import GroupNode from "../../../../CustomNodes/GroupNode";
-import ClearableEdge from "../../../../CustomEdges/ClearableEdge";
+// import FrameSelectToolbar from "../FrameSelectToolbarComponent";
+// import GroupNode from "../../../../CustomNodes/GroupNode";
+// import ClearableEdge from "../../../../CustomEdges/ClearableEdge";
 import Chat from "../../../../components/chatComponent";
-import { Button } from "../../../../components/ui/button";
+// import { Button } from "../../../../components/ui/button";
 import { TabsContext } from "../../../../contexts/tabsContext";
 import { typesContext } from "../../../../contexts/typesContext";
 import { undoRedoContext } from "../../../../contexts/undoRedoContext";
@@ -39,19 +39,26 @@ import ConnectionLineComponent from "../ConnectionLineComponent";
 import SelectionMenu from "../SelectionMenuComponent";
 import ExtraSidebar from "../extraSidebarComponent";
 import { alertContext } from "../../../../contexts/alertContext";
+import Header from "../Header";
+import { Badge } from "@/components/bs-ui/badge";
+import { LayersIcon } from "@radix-ui/react-icons";
+import { Button } from "@/components/bs-ui/button";
+import { updateVersion } from "@/controllers/API/flow";
+import { captureAndAlertRequestErrorHoc } from "@/controllers/request";
 
 const nodeTypes = {
     genericNode: GenericNode,
-    frameSelectToolbar: FrameSelectToolbar,
-    groupNode: GroupNode
+    // frameSelectToolbar: FrameSelectToolbar,
+    // groupNode: GroupNode
 };
 
 const edgeTypes = {
-    clearableEdge: ClearableEdge
+    // clearableEdge: ClearableEdge
 };
 
 export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string }) {
   let {
+    version,
     setFlow,
     setTabsState,
     saveFlow,
@@ -72,34 +79,37 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
   const { takeSnapshot } = useContext(undoRedoContext);
   // 快捷键
     const { keyBoardPanneRef, lastSelection, setLastSelection } = useKeyBoard(reactFlowWrapper)
-    const [isSelecting, setIsSelecting] = useState<Boolean>(false);
-    const [selectionNode, setSelectionNode] = useState([]);
+    // const [isSelecting, setIsSelecting] = useState<Boolean>(false);
+    // const [selectionNode, setSelectionNode] = useState([]);
     // 选区左上角坐标(x1,y1) 右下角坐标(x2,y2)
-    let selectPosition = {x1: 0, y1: 0, x2: 0, y2: 0};
+    // let selectPosition = {x1: 0, y1: 0, x2: 0, y2: 0};
     const onSelectionChange = useCallback((flowItem) => {
-    if (flowItem.nodes.length > 1) {
-        flowItem.nodes.forEach(node => {
-            selectPosition.x1 = selectPosition.x1 ? Math.min(node.position.x, selectPosition.x1) : node.position.x;
-            selectPosition.y1 = selectPosition.y1 ? Math.min(node.position.y, selectPosition.y1) : node.position.y;
-
-            selectPosition.x2 = Math.max(node.position.x + node.width, selectPosition.x2);
-            selectPosition.y2 = Math.max(node.position.y + node.height, selectPosition.y2);
-        });
-        setIsSelecting(() => true);
-        setSelectionNode(flowItem.nodes);
-    } else {
-        setIsSelecting(() => false);
-        setSelectionNode([]);
-    }
-    setLastSelection(flow);
-    }, [isSelecting, setSelectionNode]);
+      // if (flowItem.nodes.length > 1) {
+      //     flowItem.nodes.forEach(node => {
+      //         selectPosition.x1 = selectPosition.x1 ? Math.min(node.position.x, selectPosition.x1) : node.position.x;
+      //         selectPosition.y1 = selectPosition.y1 ? Math.min(node.position.y, selectPosition.y1) : node.position.y;
+
+      //         selectPosition.x2 = Math.max(node.position.x + node.width, selectPosition.x2);
+      //         selectPosition.y2 = Math.max(node.position.y + node.height, selectPosition.y2);
+      //     });
+      //     setIsSelecting(() => true);
+      //     setSelectionNode(flowItem.nodes);
+      // } else {
+      //     setIsSelecting(() => false);
+      //     setSelectionNode([]);
+      // }
+      setLastSelection(flowItem);
+    }, []);
+  // }, [isSelecting, setSelectionNode]);
 
   const [selectionMenuVisible, setSelectionMenuVisible] = useState(false);
   const [selectionEnded, setSelectionEnded] = useState(true);
 
   // Workaround to show the menu only after the selection has ended.
   useEffect(() => {
-    if (selectionEnded && lastSelection && lastSelection.nodes && lastSelection.nodes.length > 1) {
+    console.log(lastSelection)
+    // if (selectionEnded && lastSelection && lastSelection.nodes && lastSelection.nodes.length > 1) {
+    if (selectionEnded && lastSelection && lastSelection.nodes.length > 1) {
       setSelectionMenuVisible(true);
     } else {
       setSelectionMenuVisible(false);
@@ -171,39 +181,39 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
         );
 
         // 分组节点内边距
-        const groupPadding = 50;
+        // const groupPadding = 50;
         // 分组节点标题高度
-        const groupTitleHeight = 0;
+        // const groupTitleHeight = 0;
         // 创建分组
-        const createGroup = useCallback(() => {
-            let selectionIds = selectionNode.map(node => node.id);
-            let newId = getNodeId("groupNode");
-            let newNode = {
-                data: {},
-                l2_name: 'groupNode',
-                id: newId,
-                type: "groupNode",
-                style: {
-                    width: selectPosition.x2 - selectPosition.x1 + groupPadding * 2,
-                    height: selectPosition.y2 - selectPosition.y1 + groupTitleHeight + groupPadding * 2,
-                    zIndex: -1
-                },
-                position: {
-                    x: selectPosition.x1 - groupPadding,
-                    y: selectPosition.y1 - groupPadding - groupTitleHeight
-                }
-            };
-            setNodes((nds) => {
-                nds.forEach(nd => {
-                    if (selectionIds.indexOf(nd.id) >= 0) {
-                        nd.parentNode = newId;
-                        nd.position.x -= (selectPosition.x1 - groupPadding);
-                        nd.position.y -= (selectPosition.y1 - groupPadding - groupTitleHeight);
-                    }
-                });
-                return nds.concat(newNode);
-            });
-        }, [selectionNode, setNodes]);
+        // const createGroup = useCallback(() => {
+        //     let selectionIds = selectionNode.map(node => node.id);
+        //     let newId = getNodeId("groupNode");
+        //     let newNode = {
+        //         data: {},
+        //         l2_name: 'groupNode',
+        //         id: newId,
+        //         type: "groupNode",
+        //         style: {
+        //             width: selectPosition.x2 - selectPosition.x1 + groupPadding * 2,
+        //             height: selectPosition.y2 - selectPosition.y1 + groupTitleHeight + groupPadding * 2,
+        //             zIndex: -1
+        //         },
+        //         position: {
+        //             x: selectPosition.x1 - groupPadding,
+        //             y: selectPosition.y1 - groupPadding - groupTitleHeight
+        //         }
+        //     };
+        //     setNodes((nds) => {
+        //         nds.forEach(nd => {
+        //             if (selectionIds.indexOf(nd.id) >= 0) {
+        //                 nd.parentNode = newId;
+        //                 nd.position.x -= (selectPosition.x1 - groupPadding);
+        //                 nd.position.y -= (selectPosition.y1 - groupPadding - groupTitleHeight);
+        //             }
+        //         });
+        //         return nds.concat(newNode);
+        //     });
+        // }, [selectionNode, setNodes]);
 
         // const deleteGroup = useCallback((groupId) => {
         //     setNodes((nds) => {
@@ -218,28 +228,28 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
         // }, [setNodes]);
 
         // 进入多选模式后添加一个可点击的按钮节点
-        useEffect(() => {
-            if (isSelecting) {// 多选模式
-                let newNode = {
-                    selectable: false,
-                    data: {
-                        createGroup: createGroup
-                    },
-                    id: "multipartNode",
-                    type: 'frameSelectToolbar',
-                    position: {
-                        x: selectPosition.x1,
-                        y: selectPosition.y1 - 50
-                    }
-                };
-                setNodes((nds) => nds.concat(newNode));
-            } else {
-                // 延时进程队列,防止还未触发点击事件,按钮就消失
-                setTimeout(() => {
-                    setNodes((nds) => nds.filter((nd) => nd.id !== 'multipartNode'));
-                }, 100);
-            }
-        }, [isSelecting, setNodes]);
+        // useEffect(() => {
+        //     if (isSelecting) {// 多选模式
+        //         let newNode = {
+        //             selectable: false,
+        //             data: {
+        //                 createGroup: createGroup
+        //             },
+        //             id: "multipartNode",
+        //             type: 'frameSelectToolbar',
+        //             position: {
+        //                 x: selectPosition.x1,
+        //                 y: selectPosition.y1 - 50
+        //             }
+        //         };
+        //         setNodes((nds) => nds.concat(newNode));
+        //     } else {
+        //         // 延时进程队列,防止还未触发点击事件,按钮就消失
+        //         setTimeout(() => {
+        //             setNodes((nds) => nds.filter((nd) => nd.id !== 'multipartNode'));
+        //         }, 100);
+        //     }
+        // }, [isSelecting, setNodes]);
 
     // const deleteGroup = useCallback((groupId) => {
     //     setNodes((nds) => {
@@ -261,7 +271,7 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
                     {
                         ...params,
                         style: {stroke: "#555"},
-                        type: 'clearableEdge',
+                        // type: 'clearableEdge',
                         className:
                             (params.targetHandle.split("|")[0] === "Text"
                                 ? "stroke-foreground "
@@ -339,8 +349,6 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
                     y: event.clientY - reactflowBounds.top,
                 });
 
-                console.log(position);
-
                 // Generate a unique node ID
                 let {type} = data;
                 let newId = getNodeId(type);
@@ -414,7 +422,11 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
 
   // 离开并保存
   const handleSaveAndClose = async () => {
-    await saveFlow(flow, true)
+    // await saveFlow(flow, true)
+    // blocker.proceed?.()
+    setFlow('leave and save', { ...flow })
+    
+    await captureAndAlertRequestErrorHoc(updateVersion(version.id, { name: version.name, description: '', data: flow.data }))
     blocker.proceed?.()
   }
 
@@ -441,6 +453,7 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
   }, [flow.data]); // 修改 id后, 需要监听 data这一层
 
   return <>
+    <Header flow={flow}></Header>
     <div className="flex h-full overflow-hidden">
       {Object.keys(data).length ? <ExtraSidebar flow={flow} /> : <></>}
       {/* Main area */}
@@ -464,7 +477,7 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
                   disableKeyboardA11y={true}
                   onInit={setReactFlowInstance}
                   nodeTypes={nodeTypes}
-                  edgeTypes={edgeTypes}
+                  // edgeTypes={edgeTypes}
                   onEdgeUpdate={onEdgeUpdate}
                   onEdgeUpdateStart={onEdgeUpdateStart}
                   onEdgeUpdateEnd={onEdgeUpdateEnd}
@@ -540,7 +553,7 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
                         ]);
                       } else {
                         setErrorData({
-                          title: "Invalid selection",
+                          title: "无效的选择",
                           list: valiDateRes,
                         });
                       }
@@ -548,7 +561,11 @@ export default function Page({flow, preFlow}: { flow: FlowType, preFlow: string
                   />
                 </ReactFlow>
                 <Chat flow={flow} reactFlowInstance={reactFlowInstance} />
-                <p className="absolute top-0 left-[25%] w-[50%] text-[16px] mt-[14px]" style={{textAlign:"center",fontWeight:"600",color:"#fff"}}>{flow.name}</p>
+                {/* <p className="absolute top-0 left-[25%] w-[50%] text-[16px] mt-[14px]" style={{textAlign:"center",fontWeight:"600",color:"#fff"}}>{flow.name}</p> */}
+                <div className="absolute top-0 left-[25%] w-[50%] mt-[14px] flex justify-center items-center">
+                    <p className="text-[#FFFFFF] text-[16px]" style={{fontWeight:"600"}}>{flow.name}</p>
+                    <div className="w-[95px] h-[20px] bg-[#1A1A1A] text-[#999999] text-[11px] ml-[20px] flex items-center justify-center" style={{borderRadius:"10px"}}>{t('skills.currentVersion')}{version?.name}</div>
+                </div>
               </div>
             ) : (
               <></>

+ 5 - 2
src/pages/FlowPage/components/SelectionMenuComponent/index.tsx

@@ -34,7 +34,7 @@ export default function SelectionMenu({ onClick, nodes, isVisible }) {
         lastNodes && lastNodes.length > 0 ? lastNodes.map((n) => n.id) : []
       }
     >
-      <div className="overflow-hidden">
+      {/* <div className="overflow-hidden">
         <div
           className={
             "duration-400 rounded-full border border-gray-200 bg-background px-2.5 text-primary shadow-inner transition-all ease-in-out" +
@@ -42,7 +42,7 @@ export default function SelectionMenu({ onClick, nodes, isVisible }) {
           }
         >
           <button
-            className="flex gap-2 leading-8 items-center justify-between text-sm hover:scale-110 transition-all ease-in-out"
+            className="group-icon flex gap-2 leading-8 items-center justify-between text-sm hover:scale-110 transition-all ease-in-out"
             onClick={onClick}
           >
             <Combine
@@ -53,6 +53,9 @@ export default function SelectionMenu({ onClick, nodes, isVisible }) {
             Group
           </button>
         </div>
+      </div> */}
+      <div className="selected-toolbar">
+        <button id="frame-button" className="group-icon frame-button" onClick={onClick}/>
       </div>
     </NodeToolbar>
   );

+ 12 - 18
src/pages/FlowPage/components/extraSidebarComponent/index.tsx

@@ -42,7 +42,7 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
   const { notificationCenter, setNotificationCenter, setSuccessData, setErrorData } = useContext(alertContext);
   const [dataFilter, setFilterData] = useState(data);
   const [search, setSearch] = useState("");
-  const isPending = tabsState[flow.id]?.isPending;
+  // const isPending = tabsState[flow.id]?.isPending;
   const [launch, setLaunch] = useState(true);
   const [npc_zujianGengduo, setNpc_zujianGengduo] = useState(true);
   const [npc_zujianScrollTop, setNpc_zujianScrollTop] = useState(0);
@@ -102,13 +102,16 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
         <img src={launch ? dakai : shouqi} className="w-[39px]" alt=""/>
       </div>
       {/* 简化 */}
-      <div className="flex fixed right-[80px] top-4 z-10 config-bell-exit-box">
+      {/* <div className="flex fixed right-[80px] top-4 z-10 config-bell-exit-box">
         <ShadTooltip content={t('flow.simplifyConfig')} side="bottom">
           <button className="extra-side-bar-buttons whitespace-pre bg-gray-0 rounded-l-full rounded-r-none" onClick={() => setOpen(true)}>
             <Combine strokeWidth={1.5} className="side-bar-button-size pr-[2px]" color="#999999"></Combine>
-            {/*{t('flow.simplify')}*/}
           </button>
         </ShadTooltip>
+        {isPending ? <img src={rb_4_active} onClick={(event) =>
+            saveFlow(flow).then(_ =>
+              _ && setSuccessData({ title: t('success') }))
+          } className="w-[27px] ml-[1px] cursor-pointer" alt="" /> : <img src={rb_4} className="w-[27px] ml-[1px] cursor-pointer" alt="" />}
         <ShadTooltip content={t('flow.notifications')} side="bottom">
           <button
             className="extra-side-bar-buttons whitespace-pre bg-gray-0 rounded-none"
@@ -125,25 +128,16 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
           >
             {notificationCenter && <div className="header-notifications"></div>}
             <Bell className="side-bar-button-size" aria-hidden="true" color="#999999"/>
-            {/*{t('flow.notifications')}*/}
           </button>
         </ShadTooltip>
         <ShadTooltip content={t('flow.exit')} side="bottom">
           <button className="extra-side-bar-buttons whitespace-pre bg-gray-0 rounded-r-full rounded-l-none" onClick={async () => {
-            // await saveFlow(flow).then(() => {
-            //   setTimeout(() => {
-            //     navigate('/skill/' + flow.id, { replace: true })
-            //   }, 100)
-            // })
-            // setTimeout(() => {
-              navigate('/skill/' + flow.id, { replace: true })
-            // }, 100)
+            navigate('/build/skills' + flow.id, { replace: true })
           }} >
             <LogOut strokeWidth={1.5} className="side-bar-button-size pr-[2px]" color="#999999"></LogOut>
-            {/*{t('flow.exit')}*/}
           </button>
         </ShadTooltip>
-      </div>
+      </div> */}
       {/* 顶部按钮组 */}
       {/* <div className="side-bar-buttons-arrangement">
         <ShadTooltip content={t('flow.import')} side="bottom">
@@ -173,14 +167,14 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
           </button>
         </ShadTooltip>
       </div> */}
-      <div className="flex fixed right-[70px] bottom-[14px] z-10">
+      {/* <div className="flex fixed right-[70px] bottom-[14px] z-10">
         <img src={rb_1} className="w-[27px] cursor-pointer" onClick={() => { openPopUp(<ApiModal flow={flow} />); }} alt="" />
         <img src={rb_2} className="w-[27px] ml-[1px] cursor-pointer" onClick={(event) => { openPopUp(<ExportModal />); }} alt="" />
         <img src={rb_3} className="w-[27px] ml-[1px] cursor-pointer" onClick={(event) => { takeSnapshot(); uploadFlow() }} alt="" />
         {isPending ? <img src={rb_4_active} onClick={(event) =>
             saveFlow(flow).then(_ =>
               _ && setSuccessData({ title: t('success') }))
-          } className="w-[27px] ml-[1px] cursor-pointer" alt="" /> : <img src={rb_4} className="w-[27px] ml-[1px] cursor-pointer" alt="" />}
+          } className="w-[27px] ml-[1px] cursor-pointer" alt="" /> : <img src={rb_4} className="w-[27px] ml-[1px] cursor-pointer" alt="" />} */}
         {/* <ShadTooltip content={t('flow.import')} side="bottom">
           <button className="extra-side-bar-buttons" onClick={() => { takeSnapshot(); uploadFlow() }} >
             <FileUp strokeWidth={1.5} className="side-bar-button-size " ></FileUp>
@@ -207,7 +201,7 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
             <Save strokeWidth={1.5} className={"side-bar-button-size" + (isPending ? " " : " extra-side-bar-save-disable")} ></Save>
           </button>
         </ShadTooltip> */}
-      </div>
+      {/* </div> */}
       {/* <Separator /> */}
       {/* <div className="side-bar-search-div-placement">
         <input type="text" name="search" id="search" placeholder={t('flow.searchComponent')} className="input-search rounded-full"
@@ -335,7 +329,7 @@ export default function ExtraSidebar({ flow }: { flow: FlowType }) {
       </div>
       {/* 高级配置l2配置 */}
       <L2ParamsModal data={flow} open={open} setOpen={setOpen} onSave={() => {
-        saveFlow(flow, true);
+        saveFlow(flow);
         setSuccessData({ title: t('success') });
       }}></L2ParamsModal>
     </div >

+ 42 - 2
src/pages/FlowPage/components/nodeToolbarComponent/index.tsx

@@ -1,6 +1,6 @@
 import { Combine, Copy, Download, MoreHorizontal, SaveAll, Settings2, Trash2 } from "lucide-react";
 import cloneDeep from "lodash-es/cloneDeep";
-import { useContext, useState } from "react";
+import { useContext, useMemo, useState } from "react";
 import { useReactFlow } from "reactflow";
 import ShadTooltip from "../../../../components/ShadTooltipComponent";
 import { TabsContext } from "../../../../contexts/tabsContext";
@@ -13,6 +13,9 @@ import { userContext } from "../../../../contexts/userContext";
 import { bsconfirm } from "../../../../alerts/confirm";
 import { alertContext } from "../../../../contexts/alertContext";
 import React from "react";
+import { typesContext } from "@/contexts/typesContext";
+import { useNavigate, useParams } from "react-router-dom";
+import { CounterClockwiseClockIcon } from "@radix-ui/react-icons";
 
 const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
   const [nodeLength, setNodeLength] = useState(
@@ -31,7 +34,7 @@ const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
     ).length
   );
 
-  const { paste } = useContext(TabsContext);
+  const { version, paste } = useContext(TabsContext);
   const reactFlowInstance = useReactFlow();
   const isGroup = !!data.node?.flow;
 
@@ -44,6 +47,14 @@ const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
     });
   }
 
+  const { types } = useContext(typesContext);
+  const hasVersion = useMemo(() => {
+    // 部分组件开放“历史/history”入口:agent、chains、retrievers 、vector store 4类组件。
+    return ["chains", "agents", "vectorstores", "retrievers"].includes(types[data.type])
+  }, [data, types])
+
+  const navigate = useNavigate()
+  const { id: flowId } = useParams()
   const { addSavedComponent, checkComponentsName } = useContext(userContext)
   const handleSelectChange = (event) => {
     switch (event) {
@@ -80,6 +91,13 @@ const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
         break;
       case "disabled":
         break;
+      case "version":
+        navigate(`/diff/${flowId}/${version.id}/${data.id}`)
+        break;
+      case "export":
+        const cleanFlow = removeApiKeys({ data: { nodes: [{ data }] } } as any)
+        downloadNode(cleanFlow.data.nodes[0].data);
+        break;
       case "ungroup":
         takeSnapshot();
         expandGroupNode(
@@ -130,6 +148,17 @@ const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
               {/*<Copy className="h-4 w-4"></Copy>*/}
             </button>
           </ShadTooltip>
+          {/* 版本 */}
+          {
+            hasVersion && !isGroup && <ShadTooltip content="version" side="top">
+              <button
+                className="version-icon -ml-px bg-background px-2 py-2 shadow-md ring-inset transition-all hover:bg-muted"
+                onClick={() => handleSelectChange('version')}
+              >
+                {/* <CounterClockwiseClockIcon className="h-4 w-4"></CounterClockwiseClockIcon> */}
+              </button>
+            </ShadTooltip>
+          }
 
           {nodeLength > 0 && (
           <ShadTooltip content="edit" side="top">
@@ -169,6 +198,17 @@ const NodeToolbarComponent = ({ data, deleteNode, openPopUp, position }) => {
             </button>
           </ShadTooltip>
 
+          {isGroup && (
+            <ShadTooltip content="ungroup" side="top">
+              <button
+                  className="ungroup-icon -ml-px bg-background px-2 py-2 shadow-md ring-inset transition-all"
+                  onClick={() => { handleSelectChange('ungroup'); }}
+              >
+              </button>
+            </ShadTooltip>
+          )}
+          
+
           {/* more */}
           {/*<Select onValueChange={handleSelectChange} value="">*/}
           {/*  <ShadTooltip content="More" side="top">*/}

+ 15 - 0
src/routes.tsx

@@ -20,6 +20,9 @@ import L2Edit from "./pages/SkillPage/l2Edit";
 import SystemPage from "./pages/SystemPage";
 import BuildLayout from "./layout/BuildLayout";
 import Templates from "./pages/SkillPage/temps";
+import DiffFlowPage from "./pages/DiffFlowPage";
+import { ErrorBoundary } from "react-error-boundary";
+import CrashErrorComponent from "./components/CrashErrorComponent";
 
 // react 与 react router dom版本不匹配
 // const FileLibPage = lazy(() => import(/* webpackChunkName: "FileLibPage" */ "./pages/FileLibPage"));
@@ -28,6 +31,17 @@ import Templates from "./pages/SkillPage/temps";
 // const SkillChatPage = lazy(() => import(/* webpackChunkName: "SkillChatPage" */ "./pages/SkillChatPage"));
 // const FileViewPage = lazy(() => import(/* webpackChunkName: "FileViewPage" */ "./pages/FileViewPage"));
 
+const ErrorHoc = ({ Comp }) => {
+  return (
+    <ErrorBoundary
+      onReset={() => window.location.href = window.location.href}
+      FallbackComponent={CrashErrorComponent}
+    >
+      <Comp />
+    </ErrorBoundary>
+  );
+}
+
 const router = createBrowserRouter([
   {
     path: "/",
@@ -73,6 +87,7 @@ const router = createBrowserRouter([
   { path: "/chat", element: <SkillChatPage /> },
   { path: "/chat/:id/", element: <ChatShare /> },
   { path: "/report/:id/", element: <Report /> },
+  { path: "/diff/:id/:vid/:cid", element: <ErrorHoc Comp={DiffFlowPage} /> },
   // { path: "/test", element: <Test /> },
   { path: "*", element: <Navigate to="/" replace /> }
 ]);

+ 6 - 3
src/style/zk.scss

@@ -90,8 +90,8 @@
     background-image: url('../assets/npc/border-r.png');
     background-size: 30px 100%;
     background-repeat: no-repeat;
-    background-color: rgba(0, 0, 0, 0.8);
-    opacity: 90%;
+    background-color: #000;
+    opacity: 70%;
   }
   .xinDuiHua{
     display: flex;
@@ -2472,7 +2472,7 @@
     }
   }
   .border-radius-14{
-    border-radius: 14px;
+    border-radius: 14px!important;
   }
   .border-radius-7{
     border-radius: 7px;
@@ -4151,4 +4151,7 @@
     white-space: nowrap; /* 确保文本在一行内显示 */
     overflow: hidden; /* 隐藏溢出的内容 */
     text-overflow: ellipsis; /* 使用省略号表示文本溢出 */
+  }
+  .box-shadow{
+    box-shadow: 0px 0px 4px 0px rgba(255,255,255,0.5)!important;
   }

+ 3 - 1
src/types/tabs/index.ts

@@ -1,5 +1,5 @@
 import { Dispatch, SetStateAction } from "react";
-import { FlowType, TweaksType } from "../flow";
+import { FlowType, FlowVersionItem, TweaksType } from "../flow";
 
 export type TabsContextType = {
   flow: FlowType | null;
@@ -22,6 +22,8 @@ export type TabsContextType = {
   setLastCopiedSelection: (selection: { nodes: any; edges: any }) => void;
   setTweak: (tweak: TweaksType) => void;
   getTweak: TweaksType[];
+  setVersion: (version: FlowVersionItem | null) => {},
+  version: FlowVersionItem
 };
 
 export type TabsState = {