Spaces:
Sleeping
Sleeping
| import { useWorkflowStore } from "@/app/store/workflow.store"; | |
| import { | |
| NodeKind, | |
| NodeRuntimeHistory, | |
| } from "lib/ai/workflow/workflow.interface"; | |
| import { useReactFlow } from "@xyflow/react"; | |
| import { useObjectState } from "@/hooks/use-object-state"; | |
| import { UINode } from "lib/ai/workflow/workflow.interface"; | |
| import { cn, createDebounce, errorToString } from "lib/utils"; | |
| import { useCallback, useMemo, useRef, useState } from "react"; | |
| import { GraphEndEvent } from "ts-edge"; | |
| import { allNodeValidate } from "lib/ai/workflow/node-validate"; | |
| import { toast } from "sonner"; | |
| import { decodeWorkflowEvents } from "lib/ai/workflow/shared.workflow"; | |
| import { Alert, AlertDescription, AlertTitle } from "ui/alert"; | |
| import { | |
| AlertTriangleIcon, | |
| Loader, | |
| Loader2, | |
| Copy, | |
| Check, | |
| WandSparklesIcon, | |
| XIcon, | |
| Maximize2, | |
| } from "lucide-react"; | |
| import JsonView from "ui/json-view"; | |
| import { Button } from "ui/button"; | |
| import { Separator } from "ui/separator"; | |
| import { FlipWords } from "ui/flip-words"; | |
| import { Label } from "ui/label"; | |
| import { Input } from "ui/input"; | |
| import { Switch } from "ui/switch"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "ui/select"; | |
| import { Textarea } from "ui/textarea"; | |
| import { NodeIcon } from "../node-icon"; | |
| import { TextShimmer } from "ui/text-shimmer"; | |
| import { generateObjectAction } from "@/app/api/chat/actions"; | |
| import { appStore } from "@/app/store"; | |
| import { notify } from "lib/notify"; | |
| import { SelectModel } from "@/components/select-model"; | |
| import { useCopy } from "@/hooks/use-copy"; | |
| import { NodeResultPopup } from "../node-result-popup"; | |
| import { useTranslations } from "next-intl"; | |
| const debounce = createDebounce(); | |
| export function ExecuteTab({ | |
| close, | |
| onSave, | |
| }: { | |
| close: () => void; | |
| onSave: () => Promise<void>; | |
| }) { | |
| const { addProcess, processIds, workflow } = useWorkflowStore(); | |
| const tabs = useMemo( | |
| () => [ | |
| { | |
| label: "Input", | |
| value: "input", | |
| }, | |
| { | |
| label: "Result", | |
| value: "result", | |
| }, | |
| ], | |
| [], | |
| ); | |
| const [tab, setTab] = useState<(typeof tabs)[number]["value"]>(tabs[0].value); | |
| const t = useTranslations(); | |
| const [isRunning, setIsRunning] = useState(false); | |
| const [histories, setHistories] = useState<NodeRuntimeHistory[]>([]); | |
| const [result, setResult] = useState<GraphEndEvent | undefined>(); | |
| const { copied, copy } = useCopy(); | |
| const isProcessing = useMemo( | |
| () => Boolean(processIds.length), | |
| [processIds.length], | |
| ); | |
| const { getEdges, getNodes, fitView, getNode, updateNodeData, setNodes } = | |
| useReactFlow<UINode>(); | |
| const nodes = getNodes(); | |
| const historyRef = useRef<HTMLDivElement>(null); | |
| const [query, setQuery] = useObjectState({} as Record<string, any>); | |
| const startNodeData = useMemo(() => { | |
| return nodes.find((node) => node.data.kind === NodeKind.Input)!.data; | |
| }, [nodes]); | |
| const inputSchema = useMemo(() => { | |
| return startNodeData.outputSchema; | |
| }, [startNodeData]); | |
| const inputSchemaIterator = useMemo(() => { | |
| return Object.entries(inputSchema.properties ?? {}); | |
| }, [inputSchema]); | |
| const handleGenerateInputWithAI = useCallback(async () => { | |
| let model = appStore.getState().chatModel; | |
| const result = await notify.prompt({ | |
| title: t("Common.generateInputWithAI"), | |
| description: ( | |
| <div className="flex items-center gap-2"> | |
| <p className="mr-auto"> | |
| {t("Workflow.generateInputWithAIDescription")} | |
| </p> | |
| <SelectModel | |
| onSelect={(m) => { | |
| model = m; | |
| }} | |
| /> | |
| </div> | |
| ), | |
| }); | |
| if (!result) return; | |
| toast.promise( | |
| generateObjectAction({ | |
| model, | |
| prompt: { | |
| system: `You are a parameter generator for tool execution. | |
| Analyze the user's request and generate creative JSON data that matches the provided schema. | |
| If information cannot be inferred from the user's question, use your creativity to generate engaging data. | |
| Fill all required fields and return only valid JSON without explanations. | |
| tool-name: ${workflow!.name} | |
| ${workflow!.description ? `tool-description: ${workflow!.description}` : ""}`, | |
| user: result, | |
| }, | |
| schema: inputSchema, | |
| }).then((res) => { | |
| setQuery(res); | |
| }), | |
| { | |
| loading: t("Common.generatingInputWithAI"), | |
| success: t("Common.inputGeneratedSuccessfully"), | |
| error: t("Common.failedToGenerateInput"), | |
| }, | |
| ); | |
| }, [inputSchema]); | |
| const handleClick = async () => { | |
| await onSave(); | |
| const failSchema = inputSchemaIterator.find(([key]) => { | |
| if (inputSchema.required?.includes(key) && query[key] === undefined) | |
| return true; | |
| }); | |
| if (failSchema) { | |
| return toast.warning(`${failSchema[0]} is Empty`); | |
| } | |
| const validateResult = allNodeValidate({ | |
| nodes, | |
| edges: getEdges(), | |
| }); | |
| if (validateResult !== true) { | |
| if (validateResult.node) { | |
| setNodes((nds) => { | |
| return nds.map((node) => { | |
| if (node.id === validateResult.node?.id) { | |
| return { ...node, selected: true }; | |
| } | |
| if (node.selected) { | |
| return { ...node, selected: false }; | |
| } | |
| return node; | |
| }); | |
| }); | |
| } | |
| return toast.warning(validateResult.errorMessage); | |
| } | |
| run(query); | |
| }; | |
| const fitviewWithDebounce = useCallback((id: string) => { | |
| const node = getNode(id); | |
| if (!node) return; | |
| const nextNodes = getEdges() | |
| .filter((edge) => edge.source == id) | |
| .map((edge) => getNode(edge.target)) | |
| .filter(Boolean) as UINode[]; | |
| const fitviewNodes = [node, ...nextNodes]; | |
| debounce(() => { | |
| fitView({ | |
| duration: 300, | |
| nodes: fitviewNodes, | |
| maxZoom: 1.8, | |
| }); | |
| }, 300); | |
| }, []); | |
| const run = useCallback( | |
| async (query: Record<string, any>) => { | |
| const stop = addProcess(); | |
| const abortController = new AbortController(); | |
| setHistories([]); | |
| setIsRunning(true); | |
| setNodes((nds) => { | |
| return nds.map((node) => { | |
| if (node.data.runtime?.status) { | |
| return { | |
| ...node, | |
| data: { ...node.data, runtime: { status: undefined } }, | |
| }; | |
| } | |
| return node; | |
| }); | |
| }); | |
| try { | |
| const response = await fetch(`/api/workflow/${workflow!.id}/execute`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ query }), | |
| signal: abortController.signal, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const reader = response.body?.getReader(); | |
| if (!reader) { | |
| throw new Error("No readable stream available"); | |
| } | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) { | |
| break; | |
| } | |
| buffer += decoder.decode(value, { stream: true }); | |
| const { events, remainingBuffer } = decodeWorkflowEvents(buffer); | |
| buffer = remainingBuffer; | |
| for (const event of events) { | |
| switch (event.eventType) { | |
| case "WORKFLOW_START": | |
| setTab("result"); | |
| break; | |
| case "WORKFLOW_END": | |
| setResult(event); | |
| stop(); | |
| break; | |
| case "NODE_START": { | |
| fitviewWithDebounce(event.node.name); | |
| historyRef.current?.scrollTo({ | |
| top: historyRef.current?.scrollHeight, | |
| behavior: "smooth", | |
| }); | |
| updateNodeData(event.node.name, { | |
| runtime: { status: "running" }, | |
| }); | |
| setHistories((prev) => { | |
| const uiNode = getNode(event.node.name); | |
| if (!uiNode) return prev; | |
| return [ | |
| ...prev, | |
| { | |
| nodeId: event.node.name, | |
| startedAt: Date.now(), | |
| id: event.nodeExecutionId, | |
| name: uiNode.data.name, | |
| kind: uiNode.data.kind, | |
| status: "running", | |
| }, | |
| ]; | |
| }); | |
| break; | |
| } | |
| case "NODE_END": { | |
| updateNodeData(event.node.name, { | |
| runtime: { status: event.isOk ? "success" : "fail" }, | |
| }); | |
| setHistories((prev) => { | |
| const prevHistory = prev.find( | |
| (h) => h.id === event.nodeExecutionId, | |
| ); | |
| if (!prevHistory) return prev; | |
| return prev.map((n) => { | |
| if (n != prevHistory) return n; | |
| const source = event.isOk | |
| ? event.node.output | |
| : event.node.input; | |
| return { | |
| ...prevHistory, | |
| endedAt: Date.now(), | |
| status: event.isOk ? "success" : "fail", | |
| error: event.error, | |
| result: { | |
| output: source?.outputs?.[prevHistory.nodeId], | |
| input: source?.inputs?.[prevHistory.nodeId], | |
| }, | |
| } as NodeRuntimeHistory; | |
| }); | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock(); | |
| stop(); | |
| } | |
| } catch (error) { | |
| if (error instanceof Error && error.name === "AbortError") { | |
| console.log("Workflow execution was aborted"); | |
| } else { | |
| console.error("Workflow execution error:", error); | |
| } | |
| stop(); | |
| } finally { | |
| setIsRunning(false); | |
| } | |
| }, | |
| [workflow!.id], | |
| ); | |
| const lastOutput = useMemo(() => { | |
| const outputNodes = histories | |
| .filter((h) => h.kind == NodeKind.Output) | |
| .map((h) => h.result?.output) | |
| .filter(Boolean); | |
| if (outputNodes.length == 0) return undefined; | |
| if (outputNodes.length == 1) return outputNodes[0]; | |
| return outputNodes; | |
| }, [histories]); | |
| const resultView = useMemo(() => { | |
| if (isRunning) return; | |
| if (result?.isOk === false) | |
| return ( | |
| <Alert variant={"destructive"} className="border-destructive"> | |
| <AlertTriangleIcon /> | |
| <AlertTitle>Error</AlertTitle> | |
| <AlertDescription> | |
| <JsonView data={result?.error} /> | |
| </AlertDescription> | |
| </Alert> | |
| ); | |
| return ( | |
| <div className="p-2"> | |
| <JsonView data={lastOutput} /> | |
| </div> | |
| ); | |
| }, [isRunning, result]); | |
| return ( | |
| <div className="fade-300 w-sm h-[85vh] bg-card border rounded-lg shadow-lg overflow-y-auto py-4"> | |
| <div className="flex flex-col px-4"> | |
| <div className="flex items-center gap-2 w-full h-9"> | |
| <span className="font-semibold">Test Run</span> | |
| <div | |
| className={cn( | |
| "p-1 rounded hover:bg-secondary cursor-pointer ml-auto", | |
| isProcessing && "sr-only", | |
| )} | |
| onClick={close} | |
| > | |
| <XIcon className="size-3.5" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex"> | |
| {tabs.map((t) => ( | |
| <Button | |
| key={t.value} | |
| variant="ghost" | |
| className={cn( | |
| "rounded-none", | |
| tab == t.value && "border-b border-primary", | |
| )} | |
| onClick={() => setTab(t.value)} | |
| > | |
| {t.label} | |
| </Button> | |
| ))} | |
| </div> | |
| <Separator className="mb-4" /> | |
| {tab == tabs[0].value ? ( | |
| <div className="px-4 flex flex-col gap-4"> | |
| {inputSchemaIterator.length == 0 ? ( | |
| <div className="flex items-center justify-center h-40"> | |
| <FlipWords | |
| className="text-sm text-muted-foreground" | |
| words={["No input required for this workflow"]} | |
| /> | |
| </div> | |
| ) : ( | |
| <> | |
| <div | |
| tabIndex={1} | |
| onClick={handleGenerateInputWithAI} | |
| className="hover:bg-secondary rounded-sm px-2 py-1 flex items-center gap-2 ml-auto text-xs font-semibold cursor-pointer hover:text-primary transition-colors" | |
| > | |
| {t("Common.generateInputWithAI")} | |
| <WandSparklesIcon className="size-3" /> | |
| </div> | |
| {inputSchemaIterator.map(([key, schema], i) => { | |
| return ( | |
| <div key={key ?? i}> | |
| <Label | |
| className="mb-2 text-sm font-semibold ml-0.5 gap-0.5" | |
| htmlFor={key || String(i)} | |
| > | |
| {key || "undefined"} | |
| {inputSchema.required?.includes(key) && ( | |
| <span className="text-xs text-destructive">*</span> | |
| )} | |
| </Label> | |
| {schema.type == "number" ? ( | |
| <Input | |
| disabled={isProcessing} | |
| id={key || String(i)} | |
| type="number" | |
| placeholder={schema.description || "number"} | |
| defaultValue={query[key] || undefined} | |
| onChange={(e) => | |
| setQuery({ ...query, [key]: Number(e.target.value) }) | |
| } | |
| /> | |
| ) : schema.type == "boolean" ? ( | |
| <Switch | |
| disabled={isProcessing} | |
| id={key || String(i)} | |
| checked={query[key]} | |
| onCheckedChange={(checked) => | |
| setQuery({ ...query, [key]: checked }) | |
| } | |
| /> | |
| ) : schema.type == "string" && schema.enum ? ( | |
| <Select | |
| disabled={isProcessing} | |
| value={query[key]} | |
| onValueChange={(value) => | |
| setQuery({ ...query, [key]: value }) | |
| } | |
| > | |
| <SelectTrigger | |
| id={key || String(i)} | |
| className="min-w-46" | |
| > | |
| <SelectValue | |
| placeholder={schema.description || "option"} | |
| /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {(schema.enum as string[]).map((item, i) => ( | |
| <SelectItem key={item ?? i} value={item}> | |
| {item} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| ) : schema.type == "string" ? ( | |
| <Textarea | |
| disabled={isProcessing} | |
| id={key || String(i)} | |
| value={query[key]} | |
| className="resize-none max-h-28 overflow-y-auto" | |
| placeholder={schema.description || "string"} | |
| onChange={(e) => | |
| setQuery({ ...query, [key]: e.target.value }) | |
| } | |
| /> | |
| ) : null} | |
| </div> | |
| ); | |
| })} | |
| </> | |
| )} | |
| <Button | |
| disabled={isProcessing} | |
| className="font-bold w-full" | |
| onClick={handleClick} | |
| > | |
| {isProcessing ? ( | |
| <Loader className="size-3.5 animate-spin" /> | |
| ) : ( | |
| t("Common.run") | |
| )} | |
| </Button> | |
| </div> | |
| ) : tab == tabs[1].value ? ( | |
| <div> | |
| <div | |
| className="flex flex-col px-4 h-[30vh] overflow-y-auto" | |
| ref={historyRef} | |
| > | |
| {histories.map((history, i) => { | |
| return ( | |
| <NodeResultPopup history={history} key={i}> | |
| <div | |
| className={cn( | |
| "cursor-pointer hover:bg-secondary flex items-center gap-2 text-sm rounded-sm px-2 py-1.5 relative", | |
| history.status == "fail" && "text-destructive", | |
| )} | |
| > | |
| {i != 0 && ( | |
| <div className="absolute left-4.5 -top-1.5 w-px h-3"> | |
| <Separator orientation="vertical" /> | |
| </div> | |
| )} | |
| <div className="border rounded overflow-hidden"> | |
| <NodeIcon | |
| type={history.kind} | |
| iconClassName="size-3" | |
| className="rounded-none" | |
| /> | |
| </div> | |
| {history.status == "running" ? ( | |
| <TextShimmer className="font-semibold"> | |
| {`${history.name} Running...`} | |
| </TextShimmer> | |
| ) : ( | |
| <span className="font-semibold">{history.name}</span> | |
| )} | |
| <span | |
| className={cn( | |
| "ml-auto text-xs", | |
| history.status != "fail" && "text-muted-foreground", | |
| )} | |
| > | |
| {history.status != "running" && | |
| ( | |
| (history.endedAt! - history.startedAt!) / | |
| 1000 | |
| ).toFixed(2)} | |
| </span> | |
| {history.status == "success" ? ( | |
| <Check className="size-3" /> | |
| ) : history.status == "fail" ? ( | |
| <XIcon className="size-3" /> | |
| ) : ( | |
| <Loader2 className="size-3 animate-spin" /> | |
| )} | |
| </div> | |
| </NodeResultPopup> | |
| ); | |
| })} | |
| </div> | |
| <Separator /> | |
| <div className="px-4 py-4"> | |
| <div className="flex items-center mb-4"> | |
| <p className="font-semibold text-sm">Result</p> | |
| <div className="flex-1" /> | |
| {result && ( | |
| <NodeResultPopup | |
| history={{ | |
| name: "Result", | |
| status: result.isOk ? "success" : "fail", | |
| startedAt: result.startedAt, | |
| endedAt: result.endedAt, | |
| error: errorToString(result.error), | |
| result: { | |
| input: histories[0].result?.output ?? {}, | |
| output: histories.at(-1)?.result?.output ?? {}, | |
| }, | |
| }} | |
| > | |
| <Button variant={"ghost"} size={"icon"}> | |
| <Maximize2 className="size-3" /> | |
| </Button> | |
| </NodeResultPopup> | |
| )} | |
| <Button | |
| variant={"ghost"} | |
| size={"icon"} | |
| className="ml-auto" | |
| onClick={() => copy(JSON.stringify(lastOutput))} | |
| > | |
| {copied ? ( | |
| <Check className="size-3" /> | |
| ) : ( | |
| <Copy className="size-3" /> | |
| )} | |
| </Button> | |
| </div> | |
| {resultView} | |
| </div> | |
| </div> | |
| ) : null} | |
| </div> | |
| ); | |
| } | |