diff --git a/messages/en.json b/messages/en.json index 1ac73a758..c266714fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -146,7 +146,8 @@ "code": "Execute custom code scripts with access to previous node data.\n\nRun JavaScript, Python, or other languages within your workflow (coming soon).", "http": "Fetch data from external APIs and web services via HTTP requests.\n\nIntegrate with REST APIs, webhooks, and third-party services.", "template": "Create dynamic documents by combining text with data from previous nodes.\n\nGenerate emails, reports, or formatted content using variable substitution.", - "condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions." + "condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions.", + "reply-in-thread": "Create a new chat thread for the current user with scripted messages.\n\nUse '/' mentions to inject data from previous nodes before saving the conversation." }, "structuredOutputSwitchConfirm": "You currently have structured output enabled.\n What would you like to do?", "structuredOutputSwitchConfirmOk": "Edit Structured Output", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9f6e4e21c..6ab72a76a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -51,6 +51,7 @@ import { nanoBananaTool, openaiImageTool } from "lib/ai/tools/image"; import { ImageToolName } from "lib/ai/tools"; import { buildCsvIngestionPreviewParts } from "@/lib/ai/ingest/csv-ingest"; import { serverFileStorage } from "lib/file-storage"; +import type { WorkflowExecutionContext } from "lib/ai/workflow/workflow.interface"; const logger = globalLogger.withDefaults({ message: colorize("blackBright", `Chat API: `), @@ -65,6 +66,13 @@ export async function POST(request: Request) { if (!session?.user.id) { return new Response("Unauthorized", { status: 401 }); } + const workflowContext: WorkflowExecutionContext = { + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + }; const { id, message, @@ -225,6 +233,7 @@ export async function POST(request: Request) { loadWorkFlowTools({ mentions, dataStream, + context: workflowContext, }), ) .orElse({}); diff --git a/src/app/api/chat/shared.chat.ts b/src/app/api/chat/shared.chat.ts index e794efb9f..a611f895b 100644 --- a/src/app/api/chat/shared.chat.ts +++ b/src/app/api/chat/shared.chat.ts @@ -37,7 +37,11 @@ import { VercelAIWorkflowToolTag, } from "app-types/workflow"; import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor"; -import { NodeKind } from "lib/ai/workflow/workflow.interface"; +import { + NodeKind, + WorkflowExecutionContext, + withWorkflowContext, +} from "lib/ai/workflow/workflow.interface"; import { mcpClientsManager } from "lib/ai/mcp/mcp-manager"; import { APP_DEFAULT_TOOL_KIT } from "lib/ai/tools/tool-kit"; import { AppDefaultToolkit } from "lib/ai/tools"; @@ -225,12 +229,14 @@ export const workflowToVercelAITool = ({ schema, dataStream, name, + context, }: { id: string; name: string; description?: string; schema: ObjectJsonSchema7; dataStream: UIMessageStreamWriter; + context?: WorkflowExecutionContext; }): VercelAIWorkflowTool => { const toolName = name .replace(/[^a-zA-Z0-9\s]/g, "") @@ -316,9 +322,14 @@ export const workflowToVercelAITool = ({ output: toolResult, }); }); + const runtimeQuery = withWorkflowContext( + (query ?? undefined) as Record | undefined, + context, + ); + return executor.run( { - query: query ?? ({} as any), + query: runtimeQuery, }, { disableHistory: true, @@ -376,12 +387,14 @@ export const workflowToVercelAITools = ( schema: ObjectJsonSchema7; }[], dataStream: UIMessageStreamWriter, + context?: WorkflowExecutionContext, ) => { return workflows .map((v) => workflowToVercelAITool({ ...v, dataStream, + context, }), ) .reduce( @@ -409,6 +422,7 @@ export const loadMcpTools = (opt?: { export const loadWorkFlowTools = (opt: { mentions?: ChatMention[]; dataStream: UIMessageStreamWriter; + context?: WorkflowExecutionContext; }) => safe(() => opt?.mentions?.length @@ -422,7 +436,7 @@ export const loadWorkFlowTools = (opt: { ) : [], ) - .map((tools) => workflowToVercelAITools(tools, opt.dataStream)) + .map((tools) => workflowToVercelAITools(tools, opt.dataStream, opt.context)) .orElse({} as Record); export const loadAppDefaultTools = (opt?: { diff --git a/src/app/api/workflow/[id]/execute/route.ts b/src/app/api/workflow/[id]/execute/route.ts index c54362b3f..f9d7bf151 100644 --- a/src/app/api/workflow/[id]/execute/route.ts +++ b/src/app/api/workflow/[id]/execute/route.ts @@ -2,6 +2,10 @@ import { getSession } from "auth/server"; import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor"; import { workflowRepository } from "lib/db/repository"; import { encodeWorkflowEvent } from "lib/ai/workflow/shared.workflow"; +import { + withWorkflowContext, + WorkflowExecutionContext, +} from "lib/ai/workflow/workflow.interface"; import logger from "logger"; import { colorize } from "consola/utils"; import { safeJSONParse, toAny } from "lib/utils"; @@ -77,9 +81,24 @@ export async function POST( }); // Start the workflow + const workflowContext: WorkflowExecutionContext | undefined = session.user + ?.id + ? { + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + } + : undefined; + const runtimeQuery = withWorkflowContext( + query as Record | undefined, + workflowContext, + ); + app .run( - { query }, + { query: runtimeQuery }, { disableHistory: true, timeout: 1000 * 60 * 5, diff --git a/src/components/workflow/default-node.tsx b/src/components/workflow/default-node.tsx index fcd9248f6..de1cd1be0 100644 --- a/src/components/workflow/default-node.tsx +++ b/src/components/workflow/default-node.tsx @@ -25,6 +25,7 @@ import { createAppendNode } from "./create-append-node"; import { ToolNodeStack } from "./node-config/tool-node-config"; import { Markdown } from "../markdown"; import { HttpNodeDataStack } from "./node-config/http-node-config"; +import { ReplyInThreadNodeStack } from "./node-config/reply-in-thread-node-config"; type Props = NodeProps; @@ -197,6 +198,9 @@ export const DefaultNode = memo(function DefaultNode({ )} {data.kind === NodeKind.Tool && } {data.kind === NodeKind.Http && } + {data.kind === NodeKind.ReplyInThread && ( + + )} {data.description && (
diff --git a/src/components/workflow/node-config/reply-in-thread-node-config.tsx b/src/components/workflow/node-config/reply-in-thread-node-config.tsx new file mode 100644 index 000000000..6f87da3bc --- /dev/null +++ b/src/components/workflow/node-config/reply-in-thread-node-config.tsx @@ -0,0 +1,185 @@ +import { memo, useCallback } from "react"; +import { Edge, useEdges, useNodes, useReactFlow } from "@xyflow/react"; +import { MessageCirclePlusIcon, TrashIcon } from "lucide-react"; +import { Button } from "ui/button"; +import { Label } from "ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { useWorkflowStore } from "@/app/store/workflow.store"; +import { OutputSchemaMentionInput } from "../output-schema-mention-input"; +import { + ReplyInThreadNodeData, + UINode, +} from "lib/ai/workflow/workflow.interface"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { VariableIcon } from "lucide-react"; + +export const ReplyInThreadNodeConfig = memo(function ReplyInThreadNodeConfig({ + data, +}: { + data: ReplyInThreadNodeData; +}) { + const t = useTranslations(); + const { updateNodeData } = useReactFlow(); + const nodes = useNodes() as UINode[]; + const edges = useEdges() as Edge[]; + const editable = useWorkflowStore((state) => { + return ( + state.processIds.length === 0 && + state.hasEditAccess && + !state.workflow?.isPublished + ); + }); + + const updateMessage = useCallback( + ( + index: number, + message: Partial, + ) => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: prev.messages.map((m, i) => { + if (i !== index) return m; + return { ...m, ...message }; + }), + }; + }); + }, + [data.id, updateNodeData], + ); + + const removeMessage = useCallback( + (index: number) => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: prev.messages.filter((_, i) => i !== index), + }; + }); + }, + [data.id, updateNodeData], + ); + + const addMessage = useCallback(() => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: [...prev.messages, { role: "user" }], + }; + }); + }, [data.id, updateNodeData]); + + const messageHelper = useMemo(() => { + return ( + t("Workflow.messagesDescription") || + "Use '/' to reference data from previous nodes." + ); + }, [t]); + + return ( +
+
+ + + updateNodeData(data.id, { + title, + }) + } + /> +
+ +
+
+ + + + + + + {messageHelper} + + +
+
+ {data.messages.map((message, index) => ( +
+
+ + +
+ updateMessage(index, { content })} + /> +
+ ))} +
+ +
+
+ ); +}); +ReplyInThreadNodeConfig.displayName = "ReplyInThreadNodeConfig"; + +export const ReplyInThreadNodeStack = memo(function ReplyInThreadNodeStack({ + data, +}: { + data: ReplyInThreadNodeData; +}) { + return ( +
+
+ Messages + + {data.messages.length} + +
+
+ ); +}); +ReplyInThreadNodeStack.displayName = "ReplyInThreadNodeStack"; diff --git a/src/components/workflow/node-icon.tsx b/src/components/workflow/node-icon.tsx index 429258b35..091559de1 100644 --- a/src/components/workflow/node-icon.tsx +++ b/src/components/workflow/node-icon.tsx @@ -13,6 +13,7 @@ import { TerminalIcon, TextIcon, WrenchIcon, + MessageCircleReply, } from "lucide-react"; import { useMemo } from "react"; @@ -39,6 +40,8 @@ export function NodeIcon({ return HardDriveUpload; case NodeKind.Template: return TextIcon; + case NodeKind.ReplyInThread: + return MessageCircleReply; case NodeKind.Code: return TerminalIcon; default: @@ -63,9 +66,11 @@ export function NodeIcon({ ? "bg-rose-500" : type === NodeKind.Template ? "bg-purple-500" - : type === NodeKind.Condition - ? "bg-amber-500" - : "bg-card", + : type === NodeKind.ReplyInThread + ? "bg-emerald-500" + : type === NodeKind.Condition + ? "bg-amber-500" + : "bg-card", "p-1 rounded", className, )} diff --git a/src/components/workflow/selected-node-config-tab.tsx b/src/components/workflow/selected-node-config-tab.tsx index 723750104..31a3b6ee0 100644 --- a/src/components/workflow/selected-node-config-tab.tsx +++ b/src/components/workflow/selected-node-config-tab.tsx @@ -22,6 +22,7 @@ import { ToolNodeDataConfig } from "./node-config/tool-node-config"; import { HttpNodeConfig } from "./node-config/http-node-config"; import { TemplateNodeConfig } from "./node-config/template-node-config"; import { useTranslations } from "next-intl"; +import { ReplyInThreadNodeConfig } from "./node-config/reply-in-thread-node-config"; export function SelectedNodeConfigTab({ node }: { node: UINode }) { const t = useTranslations(); @@ -99,6 +100,8 @@ export function SelectedNodeConfigTab({ node }: { node: UINode }) { ) : node.data.kind === NodeKind.Template ? ( + ) : node.data.kind === NodeKind.ReplyInThread ? ( + ) : node.data.kind === NodeKind.Note ? (