Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `),
Expand All @@ -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,
Expand Down Expand Up @@ -225,6 +233,7 @@ export async function POST(request: Request) {
loadWorkFlowTools({
mentions,
dataStream,
context: workflowContext,
}),
)
.orElse({});
Expand Down
20 changes: 17 additions & 3 deletions src/app/api/chat/shared.chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, "")
Expand Down Expand Up @@ -316,9 +322,14 @@ export const workflowToVercelAITool = ({
output: toolResult,
});
});
const runtimeQuery = withWorkflowContext(
(query ?? undefined) as Record<string, unknown> | undefined,
context,
);

return executor.run(
{
query: query ?? ({} as any),
query: runtimeQuery,
},
{
disableHistory: true,
Expand Down Expand Up @@ -376,12 +387,14 @@ export const workflowToVercelAITools = (
schema: ObjectJsonSchema7;
}[],
dataStream: UIMessageStreamWriter,
context?: WorkflowExecutionContext,
) => {
return workflows
.map((v) =>
workflowToVercelAITool({
...v,
dataStream,
context,
}),
)
.reduce(
Expand Down Expand Up @@ -409,6 +422,7 @@ export const loadMcpTools = (opt?: {
export const loadWorkFlowTools = (opt: {
mentions?: ChatMention[];
dataStream: UIMessageStreamWriter;
context?: WorkflowExecutionContext;
}) =>
safe(() =>
opt?.mentions?.length
Expand All @@ -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<string, VercelAIWorkflowTool>);

export const loadAppDefaultTools = (opt?: {
Expand Down
21 changes: 20 additions & 1 deletion src/app/api/workflow/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> | undefined,
workflowContext,
);

app
.run(
{ query },
{ query: runtimeQuery },
{
disableHistory: true,
timeout: 1000 * 60 * 5,
Expand Down
4 changes: 4 additions & 0 deletions src/components/workflow/default-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<UINode>;

Expand Down Expand Up @@ -197,6 +198,9 @@ export const DefaultNode = memo(function DefaultNode({
)}
{data.kind === NodeKind.Tool && <ToolNodeStack data={data} />}
{data.kind === NodeKind.Http && <HttpNodeDataStack data={data} />}
{data.kind === NodeKind.ReplyInThread && (
<ReplyInThreadNodeStack data={data} />
)}
{data.description && (
<div className="px-4 mt-2">
<div className="text-xs text-muted-foreground">
Expand Down
185 changes: 185 additions & 0 deletions src/components/workflow/node-config/reply-in-thread-node-config.tsx
Original file line number Diff line number Diff line change
@@ -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<UINode>();
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<ReplyInThreadNodeData["messages"][number]>,
) => {
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 (
<div className="flex flex-col gap-4 h-full px-4">
<div className="flex flex-col gap-2">
<Label className="text-sm">Thread Title</Label>
<OutputSchemaMentionInput
currentNodeId={data.id}
nodes={nodes}
edges={edges}
content={data.title}
editable={editable}
onChange={(title) =>
updateNodeData(data.id, {
title,
})
}
/>
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Messages</Label>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<VariableIcon className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-64 whitespace-pre-wrap">
{messageHelper}
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-2">
{data.messages.map((message, index) => (
<div key={index} className="w-full bg-secondary rounded-md p-2">
<div className="flex items-center gap-2">
<Select
value={message.role}
onValueChange={(value) =>
updateMessage(index, {
role: value as ReplyInThreadNodeData["messages"][number]["role"],
})
}
>
<SelectTrigger className="border-none" size="sm">
{message.role.toUpperCase()}
</SelectTrigger>
<SelectContent>
<SelectItem value="user">USER</SelectItem>
<SelectItem value="assistant">ASSISTANT</SelectItem>
<SelectItem value="system">SYSTEM</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 hover:bg-destructive/10! hover:text-destructive"
onClick={() => removeMessage(index)}
disabled={!editable}
>
<TrashIcon className="size-3" />
</Button>
</div>
<OutputSchemaMentionInput
currentNodeId={data.id}
nodes={nodes}
edges={edges}
content={message.content}
editable={editable}
onChange={(content) => updateMessage(index, { content })}
/>
</div>
))}
</div>
<Button
variant="ghost"
size="icon"
className="w-full mt-1 border-dashed border text-muted-foreground"
onClick={addMessage}
disabled={!editable}
>
<MessageCirclePlusIcon className="size-4" />{" "}
{t("Workflow.addMessage")}
</Button>
</div>
</div>
);
});
ReplyInThreadNodeConfig.displayName = "ReplyInThreadNodeConfig";

export const ReplyInThreadNodeStack = memo(function ReplyInThreadNodeStack({
data,
}: {
data: ReplyInThreadNodeData;
}) {
return (
<div className="flex flex-col gap-1 px-4 mt-4 text-xs text-muted-foreground">
<div className="border bg-input rounded px-2 py-1 flex items-center gap-2">
<span className="font-semibold">Messages</span>
<span className="ml-auto text-foreground font-mono">
{data.messages.length}
</span>
</div>
</div>
);
});
ReplyInThreadNodeStack.displayName = "ReplyInThreadNodeStack";
11 changes: 8 additions & 3 deletions src/components/workflow/node-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TerminalIcon,
TextIcon,
WrenchIcon,
MessageCircleReply,
} from "lucide-react";
import { useMemo } from "react";

Expand All @@ -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:
Expand All @@ -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,
)}
Expand Down
Loading