diff --git a/dashboard/app/(copilot)/_parts/CopilotNotFound.tsx b/dashboard/app/(copilot)/_parts/CopilotNotFound.tsx new file mode 100644 index 000000000..954f6e965 --- /dev/null +++ b/dashboard/app/(copilot)/_parts/CopilotNotFound.tsx @@ -0,0 +1,12 @@ +import { EmptyBlock } from "@/components/domain/EmptyBlock"; +import React from "react"; + +export function CopilotNotFound() { + return ( +
+ +

Copilot not found

+
+
+ ); +} diff --git a/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/WorkflowsList.tsx b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/WorkflowsList.tsx index 01a4e87e6..9f3887f71 100644 --- a/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/WorkflowsList.tsx +++ b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/WorkflowsList.tsx @@ -25,6 +25,7 @@ export function WorkflowsList({ copilot_id }: { copilot_id: string }) {
  • diff --git a/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/useIsEditing.ts b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/useIsEditing.ts new file mode 100644 index 000000000..25319cd9e --- /dev/null +++ b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/_parts/useIsEditing.ts @@ -0,0 +1,8 @@ +import { useSearchParams } from "next/navigation"; + +export function useIsEditing() { + const searchParams = useSearchParams(); + const workflow_id = searchParams.get("workflow_id"); + const isEditing = !!workflow_id; + return [isEditing, workflow_id] as const; +} diff --git a/dashboard/app/(copilot)/copilot/[copilot_id]/flows/page.tsx b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/page.tsx index d506796d5..d18518574 100644 --- a/dashboard/app/(copilot)/copilot/[copilot_id]/flows/page.tsx +++ b/dashboard/app/(copilot)/copilot/[copilot_id]/flows/page.tsx @@ -1,37 +1,49 @@ "use client"; import React, { useState } from "react"; -import { - CodePreview, - Controller, - FlowArena, - useController, - transformPaths, - trasnformEndpointNodesData, -} from "@openchatai/copilot-flows-editor"; import { HeaderShell } from "@/components/domain/HeaderShell"; import { useCopilot } from "../../_context/CopilotProvider"; -import useSwr from "swr"; +import useSwr, { mutate } from "swr"; import { getSwaggerByBotId } from "@/data/swagger"; import { Button } from "@/components/ui/button"; import { createWorkflowByBotId, deleteWorkflowById, - getWorkflowsByBotId, + getWorkflowById, } from "@/data/flow"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { toast } from "@/components/ui/use-toast"; import _ from "lodash"; +import { useIsEditing } from "./_parts/useIsEditing"; +import { + Controller, + FlowArena, + transformPaths, + useController, +} from "@/components/domain/flows-editor"; function Header() { - const { id: copilotId, name: copilotName } = useCopilot(); - const { loadPaths, state } = useController(); - const saerchParams = useSearchParams(); - const workflow_id = saerchParams.get("workflow_id"); - const isEditing = !!workflow_id; - const { replace } = useRouter(); - const [loading, setLoading] = useState(false); // editing => workflow_id // creating => undefined - const { isLoading: isSwaggerLoading } = useSwr( + const [isEditing, workflow_id] = useIsEditing(); + const { replace } = useRouter(); + const { id: copilotId, name: copilotName } = useCopilot(); + const { + loadPaths, + reset: resetFlowEditor, + getData, + loadFlows, + state, + } = useController(); + useSwr(workflow_id, getWorkflowById, { + onSuccess: (data) => { + const first = _.first(data.data.flows); + if (first) { + console.log(first); + // @ts-ignore + loadFlows([first]); + } + }, + }); + const { isLoading: isSwaggerLoading, mutate: mutateSwagger } = useSwr( copilotId + "swagger_file", async () => getSwaggerByBotId(copilotId), { @@ -41,14 +53,16 @@ function Header() { }, }, ); + console.log(state); + const [loading, setLoading] = useState(false); const isLoading = isSwaggerLoading || loading; + async function handleSave() {} async function handleDelete() { if (!isEditing || !workflow_id) return; setLoading(true); const { data } = await deleteWorkflowById(workflow_id); if (data) { - console.log(data); toast({ title: "Workflow deleted", description: "Your workflow has been deleted.", @@ -57,46 +71,46 @@ function Header() { replace(`/copilot/${copilotId}/flows`, { scroll: false, }); + mutate(copilotId + "/workflows"); setLoading(false); } } async function handleCreate() { if (isEditing) return; - setLoading(true); - const firstFlow = _.first(state.flows); - const steps = trasnformEndpointNodesData(firstFlow?.steps || []); - - const { data } = await createWorkflowByBotId(copilotId, { - opencopilot: "0.1", - info: { - title: "My OpenCopilot definition", - version: "1.0.0", - }, - flows: [ - { - name: firstFlow?.name, - description: firstFlow?.description, - steps: steps.map((step) => ({ - operation: "call", - stepId: step.id, - open_api_operation_id: step.operationId, - })), - requires_confirmation: true, - on_success: [{}], - on_failure: [{}], - }, - ], - }); - if (data.workflow_id) { + const firstFlow = _.first(getData().flows); + if (firstFlow) { + setLoading(true); + try { + const { data } = await createWorkflowByBotId(copilotId, getData()); + if (data.workflow_id) { + toast({ + title: "Workflow created", + description: "Your workflow has been created.", + variant: "success", + }); + replace( + `/copilot/${copilotId}/flows/?workflow_id=${data.workflow_id}`, + { + scroll: false, + }, + ); + mutate(copilotId + "/workflows"); + } + } catch (error) { + toast({ + title: "Workflow not created", + description: "Something went wrong.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + } else { toast({ - title: "Workflow created", - description: "Your workflow has been created.", - variant: "success", - }); - replace(`/copilot/${copilotId}/flows/?workflow_id=${data.workflow_id}`, { - scroll: false, + title: "Workflow not created", + description: "You need at least one flow to create a workflow.", + variant: "destructive", }); - setLoading(false); } } return ( @@ -122,53 +136,27 @@ function Header() { + )} ); } - -export default function FlowsPage({ - params: { copilot_id }, -}: { - params: { copilot_id: string }; -}) { - const saerchParams = useSearchParams(); - const workflow_id = saerchParams.get("workflow_id"); - const isEditing = !!workflow_id; - - const { data: workflowData } = useSwr(copilot_id + "/swagger", () => - getWorkflowsByBotId(copilot_id), - ); - console.log(workflowData); +export default function FlowsPage() { return ( - // @ts-ignore - +
    -
    diff --git a/dashboard/app/(copilot)/copilot/[copilot_id]/page.tsx b/dashboard/app/(copilot)/copilot/[copilot_id]/page.tsx index afff4ba7d..ec36878ec 100644 --- a/dashboard/app/(copilot)/copilot/[copilot_id]/page.tsx +++ b/dashboard/app/(copilot)/copilot/[copilot_id]/page.tsx @@ -42,9 +42,8 @@ function InstallationSection() { // be aware to call this function when the document/window is ready. - const options = { - apiUrl: "http://localhost:8888/backend/api" // your base url where your are hosting OpenCopilot at (the API), usually it's http://localhost:5000/api + apiUrl: "http://localhost:8888/backend/api", // your base url where your are hosting OpenCopilot at (the API), usually it's http://localhost:5000/api initialMessages: ["How are the things"], // optional: you can pass an array of messages that will be sent to the copilot when it's initialized token: "${CopilotToken}", // you can get your token from the dashboard triggerSelector: "#triggerSelector", // the selector of the element that will trigger the copilot when clicked diff --git a/dashboard/app/(copilot)/copilot/_context/CopilotProvider.tsx b/dashboard/app/(copilot)/copilot/_context/CopilotProvider.tsx index 1510093e0..62373df06 100644 --- a/dashboard/app/(copilot)/copilot/_context/CopilotProvider.tsx +++ b/dashboard/app/(copilot)/copilot/_context/CopilotProvider.tsx @@ -5,6 +5,7 @@ import { createSafeContext } from "@/lib/createSafeContext"; import { useParams } from "next/navigation"; import React from "react"; import useSwr from "swr"; +import { CopilotNotFound } from "../../_parts/CopilotNotFound"; const [SafeCopilotProvider, useCopilot] = createSafeContext( "[useCopilot] should be used within a CopilotProvider", ); @@ -26,7 +27,7 @@ function CopilotProvider({ children }: { children: React.ReactNode }) { ); } else { - return
    copilot not found
    ; + return ; } } diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index 18fdbf0e0..478f5c85c 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -1,4 +1,3 @@ -@import url("@openchatai/copilot-flows-editor/dist/style.css"); @tailwind base; @tailwind components; @tailwind utilities; @@ -102,3 +101,27 @@ display: none; /* Safari and Chrome */ } } +.loading-el { + position: relative; + overflow: hidden; + pointer-events: none; + color: transparent; +} +.loading-el::after { + content: ""; + position: absolute; + inset: 0; + z-index: 10; + @apply bg-white opacity-50; +} + +.loading-el::before { + content: ""; + position: absolute; + z-index: 11; + translate: -50% -50%; + top: 50%; + left: 50%; + border-width: 3px; + @apply h-5 w-5 animate-spin rounded-full border-primary border-t-transparent; +} diff --git a/dashboard/app/swr-provider.tsx b/dashboard/app/swr-provider.tsx index 693e549dc..f17025782 100644 --- a/dashboard/app/swr-provider.tsx +++ b/dashboard/app/swr-provider.tsx @@ -2,5 +2,13 @@ import React from "react"; import { SWRConfig } from "swr"; export function SWRProvider({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/dashboard/components/domain/CodeBlock.tsx b/dashboard/components/domain/CodeBlock.tsx index b9947b4b3..a84672bd0 100644 --- a/dashboard/components/domain/CodeBlock.tsx +++ b/dashboard/components/domain/CodeBlock.tsx @@ -41,11 +41,13 @@ function CodeBlock({ padding: "20px 24px", color: "#ddd", fontFamily: '"Fira Code"', - fontSize: "13px", + fontSize: "14px", margin: "0px", borderRadius: "0.5rem", + fontWeight: 500, }, }} + wrapLongLines > {code} diff --git a/dashboard/components/domain/flows-editor/editor/AsideMenu.tsx b/dashboard/components/domain/flows-editor/editor/AsideMenu.tsx new file mode 100644 index 000000000..46fcc1c15 --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/AsideMenu.tsx @@ -0,0 +1,104 @@ +import { useMode } from "../stores/ModeProvider"; +import { PathButton } from "./PathButton"; +import { useMemo, useState } from "react"; +import { MethodBtn } from "./MethodRenderer"; +import { FlowsList } from "./FlowsList"; +import { useController } from "../stores/Controller"; +import { isEmpty } from "lodash"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; + +export function AsideMenu() { + const { + state: { paths }, + } = useController(); + + const { mode, isEdit } = useMode(); + + const [search, setSearch] = useState(""); + + const renderedPaths = useMemo( + () => + search.trim().length > 0 + ? paths.filter((path) => path.path.includes(search.trim())) + : paths, + [search, paths], + ); + + return ( + + ); +} diff --git a/dashboard/components/domain/flows-editor/editor/EndpointEdge.tsx b/dashboard/components/domain/flows-editor/editor/EndpointEdge.tsx new file mode 100644 index 000000000..998c5676c --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/EndpointEdge.tsx @@ -0,0 +1,61 @@ +import { Plus } from "lucide-react"; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getStraightPath, +} from "reactflow"; +import { useMode } from "../stores/ModeProvider"; +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; + +export function NodeEdge({ + sourceX, + sourceY, + targetX, + targetY, + ...props +}: EdgeProps) { + const [edgePath, labelX, labelY] = getStraightPath({ + sourceX, + sourceY, + targetX, + targetY, + }); + const { mode } = useMode(); + const activeEdge = useMemo(() => { + if (mode.type === "add-node-between") return mode.edge; + }, [mode]); + return ( + <> + +
    + +
    +
    + + + ); +} diff --git a/dashboard/components/domain/flows-editor/editor/EndpointNode.tsx b/dashboard/components/domain/flows-editor/editor/EndpointNode.tsx new file mode 100644 index 000000000..2edee5ae9 --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/EndpointNode.tsx @@ -0,0 +1,164 @@ +import { + Handle, + NodeProps, + Position, + useNodeId, + useNodes, + NodeToolbar, + useReactFlow, +} from "reactflow"; +import { useMode } from "../stores/ModeProvider"; +import { memo, useCallback, useMemo } from "react"; +import { Y, nodedimensions } from "./consts"; +import { PlusIcon, TrashIcon } from "lucide-react"; +import { MethodBtn } from "./MethodRenderer"; +import { NodeData } from "../types/Swagger"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { updateNodesPositions } from "../utils"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import _ from "lodash"; + +const HideHandleStyles = { + background: "transparent", + fill: "transparent", + color: "transparent", + border: "none", +}; +function EndpointNode({ data, zIndex }: NodeProps) { + const nodes = useNodes(); + const { setNodes } = useReactFlow(); + const nodeId = useNodeId(); + const nodeObj = nodes.find((n) => n.id === nodeId); + const { mode, setMode, reset: resetMode } = useMode(); + const isActive = useMemo(() => { + if (mode.type === "edit-node") { + return mode.node.id === nodeId; + } else { + return false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + const isFirstNode = nodes?.[0]?.id === nodeId; + const isLastNode = nodes?.[nodes.length - 1]?.id === nodeId; + const deleteNode = useCallback(() => { + setTimeout(() => { + setNodes( + updateNodesPositions( + nodes.filter((nd) => nd.id !== nodeId), + Y, + ), + ); + resetMode(); + }, 300); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + + + + + + + + Are you sure you want to delete this node? + + + + + + + + + + + + + {!isFirstNode && ( + + )} +
    +
    { + nodeObj && setMode({ type: "edit-node", node: nodeObj }); + }} + className={cn( + "group h-full w-full cursor-pointer select-none rounded border bg-white transition-all duration-300 ease-in-out", + isActive + ? "border-primary [box-shadow:inset_0px_2.5px_0px_0px_theme(colors.indigo.500)]" + : "border-border hover:shadow", + )} + > +
    + + {data.path} + +

    + {data.description} +

    + + {data.method} + +
    +
    + {isLastNode && ( +
    + +
    + )} +
    + + + + ); +} + +const MemoizedEndpointNode = memo(EndpointNode); +export default MemoizedEndpointNode; diff --git a/dashboard/components/domain/flows-editor/editor/FlowArena.tsx b/dashboard/components/domain/flows-editor/editor/FlowArena.tsx new file mode 100644 index 000000000..233056c2f --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/FlowArena.tsx @@ -0,0 +1,133 @@ +import ReactFlow, { + Background, + OnConnect, + addEdge, + useEdgesState, + MarkerType, + Edge, + applyNodeChanges, + NodeChange, + useReactFlow, +} from "reactflow"; +import { useCallback, useEffect, useMemo } from "react"; +import "reactflow/dist/style.css"; +import { NodeEdge } from "./EndpointEdge"; +import EndpointNode from "./EndpointNode"; +import { AsideMenu } from "./AsideMenu"; +import { useMode } from "../stores/ModeProvider"; +import { BUILDER_SCALE } from "./consts"; +import { useController } from "../stores/Controller"; + +export function FlowArena() { + const nodeTypes = useMemo( + () => ({ + endpointNode: EndpointNode, + }), + [], + ); + const edgeTypes = useMemo( + () => ({ + endpointEdge: NodeEdge, + }), + [], + ); + const { fitView } = useReactFlow(); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { + activeNodes, + setNodes, + state: { activeFlowId }, + } = useController(); + const { setMode } = useMode(); + // auto connect nodes + useEffect(() => { + if (!activeNodes) return; + if (activeNodes.length === 0) { + setMode({ type: "append-node" }); + return; + } + fitView(); + const newEdges = activeNodes + .map((v, i, a) => { + const curr = v; + const next = a.at(i + 1); + if (curr && next) { + const id = curr.id + "-" + next.id; + return { + id: id, + target: curr.id, + source: next.id, + type: "endpointEdge", + markerStart: { + type: MarkerType.ArrowClosed, + }, + }; + } + }) + .filter((v) => typeof v !== "undefined") as Edge[]; + setEdges(newEdges); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNodes]); + + const onConnect: OnConnect = useCallback( + (connection) => setEdges((eds) => addEdge(connection, eds)), + [setEdges], + ); + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const nodeChange = applyNodeChanges(changes, activeNodes || []); + setNodes(nodeChange); + }, + [setNodes, activeNodes], + ); + const empty = useMemo(() => { + return activeNodes?.length === 0 || activeFlowId === undefined; + }, [activeNodes, activeFlowId]); + return ( + <> +
    + +
    + {empty && ( +
    +
    +
    + {activeFlowId + ? "Start by selecting an endpoint from the menu" + : "Start by creating a flow"} +
    +
    +
    + )} + { + event.stopPropagation(); + event.bubbles = false; + setMode({ + type: "add-node-between", + edge: edge, + }); + }} + className="h-full w-full origin-center transition-all duration-300" + edgeTypes={edgeTypes} + maxZoom={BUILDER_SCALE} + minZoom={BUILDER_SCALE} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + deleteKeyCode={[]} + fitView + > + + +
    +
    + + ); +} diff --git a/dashboard/components/domain/flows-editor/editor/FlowsList.tsx b/dashboard/components/domain/flows-editor/editor/FlowsList.tsx new file mode 100644 index 000000000..1d170ee66 --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/FlowsList.tsx @@ -0,0 +1,179 @@ +import React, { useState } from "react"; +import { + Box, + ChevronRightIcon, + Pencil, + PlusIcon, + TrashIcon, +} from "lucide-react"; +import { useController } from "../stores/Controller"; +import { useMode } from "../stores/ModeProvider"; +import { useSettings } from "../stores/Config"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { DialogHeader } from "@/components/ui/dialog"; +import { EmptyBlock } from "../../EmptyBlock"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function FlowsList() { + const [flowsPanelOpened, setFlowsPanel] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const { reset } = useMode(); + const { maxFlows } = useSettings(); + const { + createFlow, + state: { flows, activeFlowId }, + setActiveFlow, + deleteFlow, + } = useController(); + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const [name, description, focus] = [ + data.get("name"), + data.get("description"), + data.get("focus"), + ]; + if (name && description) { + createFlow({ + createdAt: Date.now(), + name: name.toString(), + description: description.toString(), + focus: focus === "on" ? true : false, + }); + setModalOpen(false); + } + } + return ( +
    +
    + + + + + Create new flow + +
    + + + + +
    + + +
    +
    + +
    +
    +
    + { + if (maxFlows && flows.length >= maxFlows) { + alert(`You can only have ${maxFlows} flows at a time.`); + ev.preventDefault(); + return; + } + }} + > + + +
    +
    +
    + {flows.length === 0 ? ( + + ) : ( +
      + {flows?.map((flow) => { + const isActive = flow.id === activeFlowId; + return ( +
    • +
      +
      + + {flow.name} +
      +
      + + +
      +
      +
    • + ); + })} +
    + )} +
    +
    + ); +} diff --git a/dashboard/components/domain/flows-editor/editor/MethodRenderer.tsx b/dashboard/components/domain/flows-editor/editor/MethodRenderer.tsx new file mode 100644 index 000000000..9ca87c925 --- /dev/null +++ b/dashboard/components/domain/flows-editor/editor/MethodRenderer.tsx @@ -0,0 +1,34 @@ +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from "react"; +import { Method } from "../types/Swagger"; +import { cn } from "@/lib/utils"; +// BG colors for method buttons +const methodStyles = (method: Method) => { + switch (method.toUpperCase()) { + case "GET": + return "bg-green-400"; + case "POST": + return "bg-blue-400"; + case "PUT": + return "bg-yellow-400"; + case "DELETE": + return "bg-red-400"; + default: + return "bg-gray-400"; + } +}; +export const MethodBtn = forwardRef< + ElementRef<"button">, + { method: Method } & ComponentPropsWithoutRef<"button"> +>(({ method, className, ...props }, _ref) => ( +