From 6254fe37a5a9bfc056e6343ed0bcc6e91c22c881 Mon Sep 17 00:00:00 2001 From: Ryan Hopper-Lowe <46546486+ryanhopperlowe@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:00:42 -0600 Subject: [PATCH] Ui/feat/workflow-chat (#557) * feat: implement workflow chat interface * chore: rework params section in workflow form * feat: add params form to workflow invoke button * fix: load workflow directly when invoking * feat: move workflow param form to invoke button on inital trigger --- ui/admin/app/components/chat/Chat.tsx | 46 ++-- ui/admin/app/components/chat/ChatContext.tsx | 35 +-- ui/admin/app/components/chat/Chatbar.tsx | 2 +- ui/admin/app/components/chat/MessagePane.tsx | 4 +- ui/admin/app/components/chat/NoMessages.tsx | 10 +- ui/admin/app/components/chat/RunWorkflow.tsx | 73 +++++++ .../app/components/chat/RunWorkflowForm.tsx | 44 ++++ .../app/components/form/controlledInputs.tsx | 8 +- .../app/components/workflow/ParamsForm.tsx | 204 ++++++++---------- ui/admin/app/lib/service/api/primitives.ts | 4 +- ui/admin/app/lib/service/routeService.ts | 5 +- .../app/routes/_auth.workflows.$workflow.tsx | 76 +++++-- 12 files changed, 331 insertions(+), 180 deletions(-) create mode 100644 ui/admin/app/components/chat/RunWorkflow.tsx create mode 100644 ui/admin/app/components/chat/RunWorkflowForm.tsx diff --git a/ui/admin/app/components/chat/Chat.tsx b/ui/admin/app/components/chat/Chat.tsx index fe88f94b..07d190c6 100644 --- a/ui/admin/app/components/chat/Chat.tsx +++ b/ui/admin/app/components/chat/Chat.tsx @@ -1,24 +1,34 @@ import { useState } from "react"; +import { cn } from "~/lib/utils"; + import { useChat } from "~/components/chat/ChatContext"; import { Chatbar } from "~/components/chat/Chatbar"; import { MessagePane } from "~/components/chat/MessagePane"; -import { Button } from "~/components/ui/button"; +import { RunWorkflow } from "~/components/chat/RunWorkflow"; type ChatProps = React.HTMLAttributes & { showStartButton?: boolean; }; -export function Chat({ className, showStartButton = false }: ChatProps) { - const { messages, threadId, mode, invoke, readOnly } = useChat(); +export function Chat({ className }: ChatProps) { + const { + id, + messages, + threadId, + mode, + invoke, + readOnly, + isInvoking, + isRunning, + } = useChat(); const [runTriggered, setRunTriggered] = useState(false); const showMessagePane = mode === "agent" || - (mode === "workflow" && (threadId || runTriggered || !showStartButton)); + (mode === "workflow" && (threadId || runTriggered || !readOnly)); - const showStartButtonPane = - mode === "workflow" && showStartButton && !(threadId || runTriggered); + const showStartButtonPane = mode === "workflow" && !readOnly; return (
@@ -34,16 +44,28 @@ export function Chat({ className, showStartButton = false }: ChatProps) { {mode === "agent" && !readOnly && } {showStartButtonPane && ( -
- +
)}
diff --git a/ui/admin/app/components/chat/ChatContext.tsx b/ui/admin/app/components/chat/ChatContext.tsx index 71a1d948..d5d1661f 100644 --- a/ui/admin/app/components/chat/ChatContext.tsx +++ b/ui/admin/app/components/chat/ChatContext.tsx @@ -20,7 +20,7 @@ type Mode = "agent" | "workflow"; interface ChatContextType { messages: Message[]; mode: Mode; - processUserMessage: (text: string, sender: "user" | "agent") => void; + processUserMessage: (text: string) => void; id: string; threadId: Nullish; invoke: (prompt?: string) => void; @@ -46,38 +46,17 @@ export function ChatProvider({ onCreateThreadId?: (threadId: string) => void; readOnly?: boolean; }) { - /** - * processUserMessage is responsible for adding the user's message to the chat and - * triggering the agent to respond to it. - */ - const processUserMessage = (text: string, sender: "user" | "agent") => { - if (mode === "workflow" || readOnly) return; - const newMessage: Message = { text, sender }; - - // insertMessage(newMessage); - handlePrompt(newMessage.text); - }; - const invoke = (prompt?: string) => { - if (prompt && mode === "agent" && !readOnly) { - handlePrompt(prompt); - } - }; + if (readOnly) return; - const handlePrompt = (prompt: string) => { - if (prompt && mode === "agent" && !readOnly) { - invokeAgent.execute({ - slug: id, - prompt: prompt, - thread: threadId, - }); - } - // do nothing if the mode is workflow + if (mode === "workflow") invokeAgent.execute({ slug: id, prompt }); + else if (mode === "agent") + invokeAgent.execute({ slug: id, prompt, thread: threadId }); }; const invokeAgent = useAsync(InvokeService.invokeAgentWithStream, { onSuccess: ({ threadId: responseThreadId }) => { - if (responseThreadId && !threadId) { + if (responseThreadId && responseThreadId !== threadId) { // persist the threadId onCreateThreadId?.(responseThreadId); @@ -93,7 +72,7 @@ export function ChatProvider({ diff --git a/ui/admin/app/components/chat/NoMessages.tsx b/ui/admin/app/components/chat/NoMessages.tsx index 956693dc..c80c9737 100644 --- a/ui/admin/app/components/chat/NoMessages.tsx +++ b/ui/admin/app/components/chat/NoMessages.tsx @@ -6,10 +6,6 @@ import { Button } from "~/components/ui/button"; export function NoMessages() { const { processUserMessage, isInvoking } = useChat(); - const handleAddMessage = (content: string) => { - processUserMessage(content, "user"); - }; - return (

Start the conversation!

@@ -22,7 +18,7 @@ export function NoMessages() { shape="pill" disabled={isInvoking} onClick={() => - handleAddMessage( + processUserMessage( "Tell me who you are and what your objectives are." ) } @@ -35,7 +31,7 @@ export function NoMessages() { shape="pill" disabled={isInvoking} onClick={() => - handleAddMessage( + processUserMessage( "Tell me what tools you have available." ) } @@ -48,7 +44,7 @@ export function NoMessages() { shape="pill" disabled={isInvoking} onClick={() => - handleAddMessage( + processUserMessage( "Using your knowledge tools, tell me about your knowledge set." ) } diff --git a/ui/admin/app/components/chat/RunWorkflow.tsx b/ui/admin/app/components/chat/RunWorkflow.tsx new file mode 100644 index 00000000..f27a668b --- /dev/null +++ b/ui/admin/app/components/chat/RunWorkflow.tsx @@ -0,0 +1,73 @@ +import { ComponentProps, useState } from "react"; +import useSWR from "swr"; + +import { WorkflowService } from "~/lib/service/api/workflowService"; + +import { RunWorkflowForm } from "~/components/chat/RunWorkflowForm"; +import { Button, ButtonProps } from "~/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; + +type RunWorkflowProps = { + onSubmit: (params?: Record) => void; + workflowId: string; + popoverContentProps?: ComponentProps; +}; + +export function RunWorkflow({ + workflowId, + onSubmit, + ...props +}: RunWorkflowProps & ButtonProps) { + const [open, setOpen] = useState(false); + + const { data: workflow, isLoading } = useSWR( + WorkflowService.getWorkflowById.key(workflowId), + ({ workflowId }) => WorkflowService.getWorkflowById(workflowId) + ); + + const params = workflow?.params; + + if (!params || isLoading) + return ( + + ); + + return ( + + + + + + + { + setOpen(false); + onSubmit(params); + }} + /> + + + ); +} diff --git a/ui/admin/app/components/chat/RunWorkflowForm.tsx b/ui/admin/app/components/chat/RunWorkflowForm.tsx new file mode 100644 index 00000000..3b3af8e7 --- /dev/null +++ b/ui/admin/app/components/chat/RunWorkflowForm.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; + +import { ControlledInput } from "~/components/form/controlledInputs"; +import { Button } from "~/components/ui/button"; +import { Form } from "~/components/ui/form"; + +type RunWorkflowFormProps = { + params: Record; + onSubmit: (params: Record) => void; +}; + +export function RunWorkflowForm({ params, onSubmit }: RunWorkflowFormProps) { + const defaultValues = useMemo(() => { + return Object.keys(params).reduce( + (acc, key) => { + acc[key] = ""; + return acc; + }, + {} as Record + ); + }, [params]); + + const form = useForm({ defaultValues }); + const handleSubmit = form.handleSubmit(onSubmit); + + return ( +
+ + {Object.entries(params).map(([name, description]) => ( + + ))} + + + + + ); +} diff --git a/ui/admin/app/components/form/controlledInputs.tsx b/ui/admin/app/components/form/controlledInputs.tsx index 6ae25f18..1c79d5f3 100644 --- a/ui/admin/app/components/form/controlledInputs.tsx +++ b/ui/admin/app/components/form/controlledInputs.tsx @@ -43,8 +43,14 @@ export type ControlledInputProps< TName extends FieldPath, > = InputProps & BaseProps & { - classNames?: { wrapper?: string }; onChangeConversion?: (value: string) => string; + classNames?: { + wrapper?: string; + label?: string; + input?: string; + description?: string; + message?: string; + }; }; export function ControlledInput< diff --git a/ui/admin/app/components/workflow/ParamsForm.tsx b/ui/admin/app/components/workflow/ParamsForm.tsx index 3e362ea0..4af84668 100644 --- a/ui/admin/app/components/workflow/ParamsForm.tsx +++ b/ui/admin/app/components/workflow/ParamsForm.tsx @@ -1,47 +1,81 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus, TrashIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { PlusIcon, TrashIcon } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; import { z } from "zod"; import { Workflow } from "~/lib/model/workflows"; -import { noop } from "~/lib/utils"; +import { ControlledInput } from "~/components/form/controlledInputs"; import { Button } from "~/components/ui/button"; -import { Form, FormField, FormItem, FormMessage } from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; +import { Form } from "~/components/ui/form"; const formSchema = z.object({ - params: z.record(z.string(), z.string()).optional(), + params: z.array( + z.object({ + name: z.string(), + description: z.string(), + }) + ), }); export type ParamFormValues = z.infer; +type ParamValues = Workflow["params"]; + +const convertFrom = (params: ParamValues) => { + const converted = Object.entries(params || {}).map( + ([name, description]) => ({ + name, + description, + }) + ); + + return { + params: converted.length ? converted : [{ name: "", description: "" }], + }; +}; + +const convertTo = (params: ParamFormValues["params"]) => { + if (!params?.length) return undefined; + + return params.reduce((acc, param) => { + if (!param.name) return acc; + + acc[param.name] = param.description; + return acc; + }, {} as NonNullable); +}; + export function ParamsForm({ workflow, - onSubmit, onChange, }: { workflow: Workflow; - onSubmit?: (values: ParamFormValues) => void; - onChange?: (values: ParamFormValues) => void; + onChange?: (values: { params?: ParamValues }) => void; }) { + const defaultValues = useMemo( + () => convertFrom(workflow.params), + [workflow.params] + ); + const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: { params: workflow.params || {} }, + defaultValues, }); - const handleSubmit = form.handleSubmit(onSubmit || noop); - - const [newParamKey, setNewParamKey] = useState(""); - const [newParamValue, setNewParamValue] = useState(""); + const paramFields = useFieldArray({ + control: form.control, + name: "params", + }); useEffect(() => { const subscription = form.watch((value, { name, type }) => { if (name === "params" || type === "change") { const { data, success } = formSchema.safeParse(value); + if (success) { - onChange?.(data); + onChange?.({ params: convertTo(data.params) }); } } }); @@ -50,103 +84,47 @@ export function ParamsForm({ return (
- - ( - -
- - setNewParamKey(e.target.value) - } - className="flex-grow" - /> - - setNewParamValue(e.target.value) - } - className="flex-grow" - /> - -
- -
- {Object.entries(field.value || {}).map( - ([key, value], index) => ( -
- - { - const updatedParams = { - ...field.value, - }; - updatedParams[key] = - e.target.value; - field.onChange( - updatedParams - ); - }} - className="flex-grow" - /> - -
- ) - )} -
- -
- )} - /> - +
+ {paramFields.fields.map((field, i) => ( +
+ + + + + +
+ ))} + + +
); } diff --git a/ui/admin/app/lib/service/api/primitives.ts b/ui/admin/app/lib/service/api/primitives.ts index 2e5d4f31..549e0152 100644 --- a/ui/admin/app/lib/service/api/primitives.ts +++ b/ui/admin/app/lib/service/api/primitives.ts @@ -24,7 +24,7 @@ interface ExtendedAxiosRequestConfig } export async function request, D = unknown>({ - errorMessage = "Request failed", + errorMessage: _, disableTokenRefresh, ...config }: ExtendedAxiosRequestConfig): Promise { @@ -34,8 +34,6 @@ export async function request, D = unknown>({ ...config, }); } catch (error) { - console.error(errorMessage); - if (isAxiosError(error) && error.response?.status === 400) { throw new BadRequestError(error.response.data); } diff --git a/ui/admin/app/lib/service/routeService.ts b/ui/admin/app/lib/service/routeService.ts index f3a5d993..c484f7b9 100644 --- a/ui/admin/app/lib/service/routeService.ts +++ b/ui/admin/app/lib/service/routeService.ts @@ -13,6 +13,9 @@ const QuerySchemas = { workflowId: z.string().nullish(), from: z.enum(["workflows", "agents", "users"]).nullish().catch(null), }), + workflowSchema: z.object({ + threadId: z.string().nullish(), + }), } as const; function parseQuery(search: string, schema: T) { @@ -121,7 +124,7 @@ export const RouteHelperMap = { "/workflows/:workflow": { regex: exactRegex($path("/workflows/:workflow", { workflow: "(.+)" })), path: "/workflows/:workflow", - schema: z.null(), + schema: QuerySchemas.workflowSchema, }, } satisfies Record; diff --git a/ui/admin/app/routes/_auth.workflows.$workflow.tsx b/ui/admin/app/routes/_auth.workflows.$workflow.tsx index 23dc138d..654ecd6e 100644 --- a/ui/admin/app/routes/_auth.workflows.$workflow.tsx +++ b/ui/admin/app/routes/_auth.workflows.$workflow.tsx @@ -2,29 +2,81 @@ import { ClientLoaderFunctionArgs, redirect, useLoaderData, + useNavigate, } from "@remix-run/react"; -import { $params } from "remix-routes"; +import { $path } from "remix-routes"; +import { preload } from "swr"; import { WorkflowService } from "~/lib/service/api/workflowService"; -import { noop } from "~/lib/utils"; +import { RouteQueryParams, RouteService } from "~/lib/service/routeService"; +import { Chat } from "~/components/chat"; +import { ChatProvider } from "~/components/chat/ChatContext"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/ui/resizable"; import { Workflow } from "~/components/workflow"; -export const clientLoader = async ({ params }: ClientLoaderFunctionArgs) => { - const { workflow: id } = $params("/workflows/:workflow", params); +export type SearchParams = RouteQueryParams<"workflowSchema">; + +export const clientLoader = async ({ + params, + request, +}: ClientLoaderFunctionArgs) => { + const { pathParams, query } = RouteService.getRouteInfo( + "/workflows/:workflow", + new URL(request.url), + params + ); - if (!id) { - throw redirect("/threads"); - } + if (!pathParams.workflow) throw redirect($path("/workflows")); - const workflow = await WorkflowService.getWorkflowById(id).catch(noop); - if (!workflow) throw redirect("/agents"); + const workflow = await preload( + WorkflowService.getWorkflowById.key(pathParams.workflow), + () => WorkflowService.getWorkflowById(pathParams.workflow) + ); - return { workflow }; + if (!workflow) throw redirect($path("/workflows")); + + return { workflow, threadId: query?.threadId }; }; export default function ChatAgent() { - const { workflow } = useLoaderData(); + const { workflow, threadId } = useLoaderData(); + + const navigate = useNavigate(); - return ; + return ( +
+ + navigate( + $path( + "/workflows/:workflow", + { workflow: workflow.id }, + { threadId } + ) + ) + } + > + + + + + + + + + + +
+ ); }