diff --git a/.env.example b/.env.example index 7bccd6955..37b5a8b88 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ E2E_DEFAULT_MODEL= # If you don't have PostgreSQL running locally, start it with: pnpm docker:pg POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name +# Secret used to authorize workflow scheduler dispatches (set any random string) +WORKFLOW_SCHEDULER_SECRET= + # Secret for Better Auth (generate with: npx @better-auth/cli@latest secret) BETTER_AUTH_SECRET=**** diff --git a/README.md b/README.md index c63d66ea3..a0b771952 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Built with Vercel AI SDK and Next.js, combining the best features of leading AI - [🔐 OAuth Sign-In Setup](#-oauth-sign-in-setup) - [🕵🏿 Adding openAI like providers](#-adding-openai-like-providers) - [🧪 E2E Testing Guide](#-e2e-testing-guide) + - [🕒 Workflow Scheduler](#-workflow-scheduler) - [💡 Tips](#-tips) - [💬 Temporary Chat Windows](#-temporary-chat-windows) - [🗺️ Roadmap](#️-roadmap) @@ -306,6 +307,10 @@ BETTER_AUTH_URL= # If you don't have PostgreSQL running locally, start it with: pnpm docker:pg POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name +# === Workflow Scheduler === +# Shared secret required by /api/workflow/schedules/dispatch +WORKFLOW_SCHEDULER_SECRET=your_random_string + # (Optional) # === Tools === # Exa AI for web search and content extraction (optional, but recommended for @web and research features) @@ -395,6 +400,10 @@ Step-by-step setup guides for running and configuring better-chatbot. #### [🧪 E2E Testing Guide](./docs/tips-guides/e2e-testing-guide.md) - Comprehensive end-to-end testing with Playwright including multi-user scenarios, agent visibility testing, and CI/CD integration + +#### [🕒 Workflow Scheduler](./docs/tips-guides/workflow-scheduler.md) + +- Configure Scheduler nodes, set the shared secret, and wire cron jobs to `/api/workflow/schedules/dispatch`
## 💡 Tips diff --git a/docs/tips-guides/workflow-scheduler.md b/docs/tips-guides/workflow-scheduler.md new file mode 100644 index 000000000..48c59b992 --- /dev/null +++ b/docs/tips-guides/workflow-scheduler.md @@ -0,0 +1,129 @@ +# Workflow Scheduler + +Trigger published workflows on a recurring cadence by combining the Scheduler node with the `/api/workflow/schedules/dispatch` endpoint. This guide explains what you need to configure, how schedule execution works, and a few examples for wiring it up to cron jobs or external task runners. + +## Requirements + +- **Workflow Scheduler Secret** – set `WORKFLOW_SCHEDULER_SECRET` in `.env` (any random string). Every dispatch request must present this secret. +- **Published workflow** – only published workflows that contain at least one Scheduler node are eligible to run. Draft workflows are ignored. +- **Cron or job runner** – you must call the dispatch endpoint on a cadence (e.g., Vercel Cron, GitHub Actions, Cloudflare Workers, Kubernetes CronJob, or a local `cron` entry). + +## Configuring Scheduler Nodes + +1. Add a **Scheduler** node to your workflow and fill in: + - `cron` – standard 5-part cron expression (validated via `cron-parser`). + - `timezone` – Olson TZ string (defaults to the workflow owner's timezone). + - `enabled` – scheduler rows are skipped when disabled. + - `payload` – optional JSON object passed as the workflow input when this schedule runs. +2. Publish the workflow. Saving/publishing will upsert the node's schedule in the `workflow_schedule` table and compute the next run time. + +When a schedule fires, the workflow executor runs with: + +- The node's `payload` merged into the execution `query`. +- Optional workflow context containing the owner's id, name, and email (when available). +- History disabled and a 5-minute timeout to keep scheduler runs short-lived. + +## Dispatch Endpoint + +``` +POST /api/workflow/schedules/dispatch +``` + +### Authentication + +Send the scheduler secret by using one of the supported headers: + +- `Authorization: Bearer ` +- `x-workflow-scheduler-secret: ` +- `x-cron-secret: ` + +The request is rejected with `401 Unauthorized` when the secret is missing or mismatched. A `500` error indicates the secret is not configured on the server. + +### Request Body + +`Content-Type: application/json` with the following optional fields: + +- `limit` – maximum number of schedules to process (default `5`, min `1`, max `25`). +- `dryRun` – when `true`, schedules are locked then immediately released (useful for monitoring or smoke tests). + +### Response Shape + +```json +{ + "ok": true, + "result": { + "scanned": 3, + "locked": 2, + "success": 2, + "failed": 0, + "skipped": 1, + "errors": [] + } +} +``` + +- `scanned` – due schedules inspected during this dispatch. +- `locked` – schedules successfully locked by this worker. +- `success` / `failed` – execution outcome counts. +- `skipped` – schedules skipped because they were already locked, disabled, or the request was a dry run. +- `errors` – array of `{ scheduleId, message }` entries for failed runs. + +Locks automatically expire after five minutes to protect against stuck workers. Each successful run recomputes the next run time using the stored cron expression. + +## Example Cron Invocations + +### Local cron (every minute) + +```bash +* * * * * curl -s -X POST \ + -H "x-workflow-scheduler-secret: $WORKFLOW_SCHEDULER_SECRET" \ + https://your-domain.com/api/workflow/schedules/dispatch > /dev/null +``` + +### Vercel Cron Job + +1. Set `WORKFLOW_SCHEDULER_SECRET` in your Vercel project settings. +2. Add a cron entry in `vercel.json`: + +```json +{ + "crons": [ + { + "path": "/api/workflow/schedules/dispatch", + "schedule": "*/5 * * * *", + "headers": { + "x-workflow-scheduler-secret": "@WORKFLOW_SCHEDULER_SECRET" + } + } + ] +} +``` + +Vercel automatically injects the secret value referenced by the `@` syntax. + +### GitHub Actions + +```yaml +name: Workflow Scheduler +on: + schedule: + - cron: "*/10 * * * *" +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Trigger schedules + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.WORKFLOW_SCHEDULER_SECRET }}" \ + https://your-domain.com/api/workflow/schedules/dispatch +``` + +## Troubleshooting + +- **`Unauthorized`** – confirm the header value matches `WORKFLOW_SCHEDULER_SECRET` on the server. +- **`ok: true` but `skipped` > 0** – another worker already locked those schedules, or the request used `dryRun: true`. +- **Workflows never run** – ensure the workflow is published and the Scheduler node is enabled with a valid cron + timezone. +- **Need visibility** – temporarily run with `dryRun: true` to gather lock stats without executing flows. + +With these steps in place, your Scheduler nodes will run reliably at whatever cadence you define. diff --git a/messages/en.json b/messages/en.json index 1ac73a758..1dde576c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -146,7 +146,9 @@ "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.", + "scheduler": "Trigger workflows on a recurring schedule defined by cron expressions.\n\nUse timezone-aware schedules to automate recurring tasks without manual input." }, "structuredOutputSwitchConfirm": "You currently have structured output enabled.\n What would you like to do?", "structuredOutputSwitchConfirmOk": "Edit Structured Output", @@ -154,7 +156,22 @@ "noTools": "No published workflows available.\nCreate workflows to build custom tools.", "arrangeNodes": "Auto Layout", "nodesArranged": "Layout applied successfully", - "visibilityUpdated": "Visibility updated successfully" + "visibilityUpdated": "Visibility updated successfully", + "schedulerCronExpression": "Cron expression", + "schedulerCronHelper": "Use standard 5-field cron syntax. Examples: '0 * * * *' or '0 9 * * MON'.", + "schedulerCronDocs": "Open crontab.guru", + "schedulerTimezone": "Timezone", + "schedulerTimezoneHelper": "Use an IANA timezone like 'UTC' or 'America/New_York'.", + "schedulerEnabled": "Enabled", + "schedulerEnabledDescription": "Paused schedules will not run.", + "schedulerPayload": "Payload", + "schedulerPayloadDescription": "JSON payload supplied as workflow input when the schedule triggers.", + "schedulerInvalidJson": "Please enter valid JSON.", + "schedulerStackCronLabel": "Cron", + "schedulerStackTimezoneLabel": "TZ", + "schedulerStackStatusLabel": "Status", + "schedulerStatusActive": "Active", + "schedulerStatusPaused": "Paused" }, "Auth": { "SignIn": { @@ -247,7 +264,8 @@ "visualization": "Data Visualization", "webSearch": "Search the Web", "http": "HTTP Request", - "code": "Code Execution" + "code": "Code Execution", + "automation": "Pulse Automation" } }, "VoiceChat": { diff --git a/package.json b/package.json index 918b8586c..fce9dc805 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "consola": "^3.4.2", + "cron-parser": "^5.4.0", "date-fns": "^4.1.0", "deepmerge": "^4.3.1", "dotenv": "^16.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d75e314f3..d6744d4dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 + cron-parser: + specifier: ^5.4.0 + version: 5.4.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -3531,6 +3534,10 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@5.4.0: + resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==} + engines: {node: '>=18'} + cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -4933,6 +4940,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -9934,6 +9945,10 @@ snapshots: crelt@1.0.6: {} + cron-parser@5.4.0: + dependencies: + luxon: 3.7.2 + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -11571,6 +11586,8 @@ snapshots: dependencies: react: 19.2.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9f6e4e21c..0f11f1b72 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -22,6 +22,7 @@ import { } from "lib/ai/prompts"; import { chatApiSchemaRequestBodySchema, + ChatApiSchemaRequestBody, ChatMention, ChatMetadata, } from "app-types/chat"; @@ -51,6 +52,8 @@ 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"; +import { withRequestContext } from "lib/request-context"; const logger = globalLogger.withDefaults({ message: colorize("blackBright", `Chat API: `), @@ -65,327 +68,364 @@ export async function POST(request: Request) { if (!session?.user.id) { return new Response("Unauthorized", { status: 401 }); } - const { - id, - message, - chatModel, - toolChoice, - allowedAppDefaultToolkit, - allowedMcpServers, - imageTool, - mentions = [], - attachments = [], - } = chatApiSchemaRequestBodySchema.parse(json); - - const model = customModelProvider.getModel(chatModel); - - let thread = await chatRepository.selectThreadDetails(id); - - if (!thread) { - logger.info(`create chat thread: ${id}`); - const newThread = await chatRepository.insertThread({ - id, - title: "", - userId: session.user.id, - }); - thread = await chatRepository.selectThreadDetails(newThread.id); - } - if (thread!.userId !== session.user.id) { - return new Response("Forbidden", { status: 403 }); - } + const payload = chatApiSchemaRequestBodySchema.parse(json); + const authedSession = session!; + + return withRequestContext( + { + userId: authedSession.user.id, + userEmail: authedSession.user.email, + userName: authedSession.user.name, + clientTimezone: payload.clientTimezone, + threadId: payload.id, + chatModel: payload.chatModel, + }, + () => + handleChatRequest({ + request, + session: authedSession, + payload, + }), + ); + } catch (error: any) { + logger.error(error); + return Response.json({ message: error.message }, { status: 500 }); + } +} - const messages: UIMessage[] = (thread?.messages ?? []).map((m) => { - return { - id: m.id, - role: m.role, - parts: m.parts, - metadata: m.metadata, - }; +async function handleChatRequest({ + request, + session, + payload, +}: { + request: Request; + session: NonNullable>>; + payload: ChatApiSchemaRequestBody; +}) { + const workflowContext: WorkflowExecutionContext = { + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + }; + const { + id, + message, + chatModel, + toolChoice, + allowedAppDefaultToolkit, + allowedMcpServers, + imageTool, + mentions = [], + attachments = [], + } = payload; + + const model = customModelProvider.getModel(chatModel); + + let thread = await chatRepository.selectThreadDetails(id); + + if (!thread) { + logger.info(`create chat thread: ${id}`); + const newThread = await chatRepository.insertThread({ + id, + title: "", + userId: session.user.id, }); + thread = await chatRepository.selectThreadDetails(newThread.id); + } - if (messages.at(-1)?.id == message.id) { - messages.pop(); - } - const ingestionPreviewParts = await buildCsvIngestionPreviewParts( - attachments, - (key) => serverFileStorage.download(key), - ); - if (ingestionPreviewParts.length) { - const baseParts = [...message.parts]; - let insertionIndex = -1; - for (let i = baseParts.length - 1; i >= 0; i -= 1) { - if (baseParts[i]?.type === "text") { - insertionIndex = i; - break; - } - } - if (insertionIndex !== -1) { - baseParts.splice(insertionIndex, 0, ...ingestionPreviewParts); - message.parts = baseParts; - } else { - message.parts = [...baseParts, ...ingestionPreviewParts]; + if (thread!.userId !== session.user.id) { + return new Response("Forbidden", { status: 403 }); + } + + const messages: UIMessage[] = (thread?.messages ?? []).map((m) => { + return { + id: m.id, + role: m.role, + parts: m.parts, + metadata: m.metadata, + }; + }); + + if (messages.at(-1)?.id == message.id) { + messages.pop(); + } + const ingestionPreviewParts = await buildCsvIngestionPreviewParts( + attachments, + (key) => serverFileStorage.download(key), + ); + if (ingestionPreviewParts.length) { + const baseParts = [...message.parts]; + let insertionIndex = -1; + for (let i = baseParts.length - 1; i >= 0; i -= 1) { + if (baseParts[i]?.type === "text") { + insertionIndex = i; + break; } } + if (insertionIndex !== -1) { + baseParts.splice(insertionIndex, 0, ...ingestionPreviewParts); + message.parts = baseParts; + } else { + message.parts = [...baseParts, ...ingestionPreviewParts]; + } + } - if (attachments.length) { - const firstTextIndex = message.parts.findIndex( - (part: any) => part?.type === "text", - ); - const attachmentParts: any[] = []; + if (attachments.length) { + const firstTextIndex = message.parts.findIndex( + (part: any) => part?.type === "text", + ); + const attachmentParts: any[] = []; - attachments.forEach((attachment) => { - const exists = message.parts.some( - (part: any) => - part?.type === attachment.type && part?.url === attachment.url, - ); - if (exists) return; - - if (attachment.type === "file") { - attachmentParts.push({ - type: "file", - url: attachment.url, - mediaType: attachment.mediaType, - filename: attachment.filename, - }); - } else if (attachment.type === "source-url") { - attachmentParts.push({ - type: "source-url", - url: attachment.url, - mediaType: attachment.mediaType, - title: attachment.filename, - }); - } - }); + attachments.forEach((attachment) => { + const exists = message.parts.some( + (part: any) => + part?.type === attachment.type && part?.url === attachment.url, + ); + if (exists) return; + + if (attachment.type === "file") { + attachmentParts.push({ + type: "file", + url: attachment.url, + mediaType: attachment.mediaType, + filename: attachment.filename, + }); + } else if (attachment.type === "source-url") { + attachmentParts.push({ + type: "source-url", + url: attachment.url, + mediaType: attachment.mediaType, + title: attachment.filename, + }); + } + }); - if (attachmentParts.length) { - if (firstTextIndex >= 0) { - message.parts = [ - ...message.parts.slice(0, firstTextIndex), - ...attachmentParts, - ...message.parts.slice(firstTextIndex), - ]; - } else { - message.parts = [...message.parts, ...attachmentParts]; - } + if (attachmentParts.length) { + if (firstTextIndex >= 0) { + message.parts = [ + ...message.parts.slice(0, firstTextIndex), + ...attachmentParts, + ...message.parts.slice(firstTextIndex), + ]; + } else { + message.parts = [...message.parts, ...attachmentParts]; } } + } - messages.push(message); + messages.push(message); - const supportToolCall = !isToolCallUnsupportedModel(model); + const supportToolCall = !isToolCallUnsupportedModel(model); - const agentId = ( - mentions.find((m) => m.type === "agent") as Extract< - ChatMention, - { type: "agent" } - > - )?.agentId; + const agentId = ( + mentions.find((m) => m.type === "agent") as Extract< + ChatMention, + { type: "agent" } + > + )?.agentId; - const agent = await rememberAgentAction(agentId, session.user.id); + const agent = await rememberAgentAction(agentId, session.user.id); - if (agent?.instructions?.mentions) { - mentions.push(...agent.instructions.mentions); - } + if (agent?.instructions?.mentions) { + mentions.push(...agent.instructions.mentions); + } - const useImageTool = Boolean(imageTool?.model); + const useImageTool = Boolean(imageTool?.model); + + const isToolCallAllowed = + supportToolCall && + (toolChoice != "none" || mentions.length > 0) && + !useImageTool; + + const metadata: ChatMetadata = { + agentId: agent?.id, + toolChoice: toolChoice, + toolCount: 0, + chatModel: chatModel, + }; + + const stream = createUIMessageStream({ + execute: async ({ writer: dataStream }) => { + const mcpClients = await mcpClientsManager.getClients(); + const mcpTools = await mcpClientsManager.tools(); + logger.info( + `mcp-server count: ${mcpClients.length}, mcp-tools count :${Object.keys(mcpTools).length}`, + ); + const MCP_TOOLS = await safe() + .map(errorIf(() => !isToolCallAllowed && "Not allowed")) + .map(() => + loadMcpTools({ + mentions, + allowedMcpServers, + }), + ) + .orElse({}); + + const WORKFLOW_TOOLS = await safe() + .map(errorIf(() => !isToolCallAllowed && "Not allowed")) + .map(() => + loadWorkFlowTools({ + mentions, + dataStream, + context: workflowContext, + }), + ) + .orElse({}); + + const APP_DEFAULT_TOOLS = await safe() + .map(errorIf(() => !isToolCallAllowed && "Not allowed")) + .map(() => + loadAppDefaultTools({ + mentions, + allowedAppDefaultToolkit, + }), + ) + .orElse({}); + const inProgressToolParts = extractInProgressToolPart(message); + if (inProgressToolParts.length) { + await Promise.all( + inProgressToolParts.map(async (part) => { + const output = await manualToolExecuteByLastMessage( + part, + { ...MCP_TOOLS, ...WORKFLOW_TOOLS, ...APP_DEFAULT_TOOLS }, + request.signal, + ); + part.output = output; + + dataStream.write({ + type: "tool-output-available", + toolCallId: part.toolCallId, + output, + }); + }), + ); + } - const isToolCallAllowed = - supportToolCall && - (toolChoice != "none" || mentions.length > 0) && - !useImageTool; + const userPreferences = thread?.userPreferences || undefined; - const metadata: ChatMetadata = { - agentId: agent?.id, - toolChoice: toolChoice, - toolCount: 0, - chatModel: chatModel, - }; + const mcpServerCustomizations = await safe() + .map(() => { + if (Object.keys(MCP_TOOLS ?? {}).length === 0) + throw new Error("No tools found"); + return rememberMcpServerCustomizationsAction(session.user.id); + }) + .map((v) => filterMcpServerCustomizations(MCP_TOOLS!, v)) + .orElse({}); - const stream = createUIMessageStream({ - execute: async ({ writer: dataStream }) => { - const mcpClients = await mcpClientsManager.getClients(); - const mcpTools = await mcpClientsManager.tools(); - logger.info( - `mcp-server count: ${mcpClients.length}, mcp-tools count :${Object.keys(mcpTools).length}`, - ); - const MCP_TOOLS = await safe() - .map(errorIf(() => !isToolCallAllowed && "Not allowed")) - .map(() => - loadMcpTools({ - mentions, - allowedMcpServers, - }), - ) - .orElse({}); - - const WORKFLOW_TOOLS = await safe() - .map(errorIf(() => !isToolCallAllowed && "Not allowed")) - .map(() => - loadWorkFlowTools({ - mentions, - dataStream, - }), - ) - .orElse({}); - - const APP_DEFAULT_TOOLS = await safe() - .map(errorIf(() => !isToolCallAllowed && "Not allowed")) - .map(() => - loadAppDefaultTools({ - mentions, - allowedAppDefaultToolkit, - }), - ) - .orElse({}); - const inProgressToolParts = extractInProgressToolPart(message); - if (inProgressToolParts.length) { - await Promise.all( - inProgressToolParts.map(async (part) => { - const output = await manualToolExecuteByLastMessage( - part, - { ...MCP_TOOLS, ...WORKFLOW_TOOLS, ...APP_DEFAULT_TOOLS }, - request.signal, - ); - part.output = output; - - dataStream.write({ - type: "tool-output-available", - toolCallId: part.toolCallId, - output, - }); - }), - ); - } - - const userPreferences = thread?.userPreferences || undefined; - - const mcpServerCustomizations = await safe() - .map(() => { - if (Object.keys(MCP_TOOLS ?? {}).length === 0) - throw new Error("No tools found"); - return rememberMcpServerCustomizationsAction(session.user.id); - }) - .map((v) => filterMcpServerCustomizations(MCP_TOOLS!, v)) - .orElse({}); - - const systemPrompt = mergeSystemPrompt( - buildUserSystemPrompt(session.user, userPreferences, agent), - buildMcpServerCustomizationsSystemPrompt(mcpServerCustomizations), - !supportToolCall && buildToolCallUnsupportedModelSystemPrompt, - ); + const systemPrompt = mergeSystemPrompt( + buildUserSystemPrompt(session.user, userPreferences, agent), + buildMcpServerCustomizationsSystemPrompt(mcpServerCustomizations), + !supportToolCall && buildToolCallUnsupportedModelSystemPrompt, + ); - const IMAGE_TOOL: Record = useImageTool - ? { - [ImageToolName]: - imageTool?.model === "google" - ? nanoBananaTool - : openaiImageTool, - } - : {}; - const vercelAITooles = safe({ - ...MCP_TOOLS, - ...WORKFLOW_TOOLS, + const IMAGE_TOOL: Record = useImageTool + ? { + [ImageToolName]: + imageTool?.model === "google" ? nanoBananaTool : openaiImageTool, + } + : {}; + const vercelAITooles = safe({ + ...MCP_TOOLS, + ...WORKFLOW_TOOLS, + }) + .map((t) => { + const bindingTools = + toolChoice === "manual" || + (message.metadata as ChatMetadata)?.toolChoice === "manual" + ? excludeToolExecution(t) + : t; + return { + ...bindingTools, + ...APP_DEFAULT_TOOLS, // APP_DEFAULT_TOOLS Not Supported Manual + ...IMAGE_TOOL, + }; }) - .map((t) => { - const bindingTools = - toolChoice === "manual" || - (message.metadata as ChatMetadata)?.toolChoice === "manual" - ? excludeToolExecution(t) - : t; - return { - ...bindingTools, - ...APP_DEFAULT_TOOLS, // APP_DEFAULT_TOOLS Not Supported Manual - ...IMAGE_TOOL, - }; - }) - .unwrap(); - metadata.toolCount = Object.keys(vercelAITooles).length; - - const allowedMcpTools = Object.values(allowedMcpServers ?? {}) - .map((t) => t.tools) - .flat(); + .unwrap(); + metadata.toolCount = Object.keys(vercelAITooles).length; - logger.info( - `${agent ? `agent: ${agent.name}, ` : ""}tool mode: ${toolChoice}, mentions: ${mentions.length}`, - ); + const allowedMcpTools = Object.values(allowedMcpServers ?? {}) + .map((t) => t.tools) + .flat(); + logger.info( + `${agent ? `agent: ${agent.name}, ` : ""}tool mode: ${toolChoice}, mentions: ${mentions.length}`, + ); + + logger.info( + `allowedMcpTools: ${allowedMcpTools.length ?? 0}, allowedAppDefaultToolkit: ${allowedAppDefaultToolkit?.length ?? 0}`, + ); + if (useImageTool) { + logger.info(`binding tool count Image: ${imageTool?.model}`); + } else { logger.info( - `allowedMcpTools: ${allowedMcpTools.length ?? 0}, allowedAppDefaultToolkit: ${allowedAppDefaultToolkit?.length ?? 0}`, + `binding tool count APP_DEFAULT: ${Object.keys(APP_DEFAULT_TOOLS ?? {}).length}, MCP: ${Object.keys(MCP_TOOLS ?? {}).length}, Workflow: ${Object.keys(WORKFLOW_TOOLS ?? {}).length}`, ); - if (useImageTool) { - logger.info(`binding tool count Image: ${imageTool?.model}`); - } else { - logger.info( - `binding tool count APP_DEFAULT: ${Object.keys(APP_DEFAULT_TOOLS ?? {}).length}, MCP: ${Object.keys(MCP_TOOLS ?? {}).length}, Workflow: ${Object.keys(WORKFLOW_TOOLS ?? {}).length}`, - ); - } - logger.info(`model: ${chatModel?.provider}/${chatModel?.model}`); - - const result = streamText({ - model, - system: systemPrompt, - messages: convertToModelMessages(messages), - experimental_transform: smoothStream({ chunking: "word" }), - maxRetries: 2, - tools: vercelAITooles, - stopWhen: stepCountIs(10), - toolChoice: "auto", - abortSignal: request.signal, + } + logger.info(`model: ${chatModel?.provider}/${chatModel?.model}`); + + const result = streamText({ + model, + system: systemPrompt, + messages: convertToModelMessages(messages), + experimental_transform: smoothStream({ chunking: "word" }), + maxRetries: 2, + tools: vercelAITooles, + stopWhen: stepCountIs(10), + toolChoice: "auto", + abortSignal: request.signal, + }); + result.consumeStream(); + dataStream.merge( + result.toUIMessageStream({ + messageMetadata: ({ part }) => { + if (part.type == "finish") { + metadata.usage = part.totalUsage; + return metadata; + } + }, + }), + ); + }, + + generateId: generateUUID, + onFinish: async ({ responseMessage }) => { + if (responseMessage.id == message.id) { + await chatRepository.upsertMessage({ + threadId: thread!.id, + ...responseMessage, + parts: responseMessage.parts.map(convertToSavePart), + metadata, }); - result.consumeStream(); - dataStream.merge( - result.toUIMessageStream({ - messageMetadata: ({ part }) => { - if (part.type == "finish") { - metadata.usage = part.totalUsage; - return metadata; - } - }, - }), - ); - }, - - generateId: generateUUID, - onFinish: async ({ responseMessage }) => { - if (responseMessage.id == message.id) { - await chatRepository.upsertMessage({ - threadId: thread!.id, - ...responseMessage, - parts: responseMessage.parts.map(convertToSavePart), - metadata, - }); - } else { - await chatRepository.upsertMessage({ - threadId: thread!.id, - role: message.role, - parts: message.parts.map(convertToSavePart), - id: message.id, - }); - await chatRepository.upsertMessage({ - threadId: thread!.id, - role: responseMessage.role, - id: responseMessage.id, - parts: responseMessage.parts.map(convertToSavePart), - metadata, - }); - } - - if (agent) { - agentRepository.updateAgent(agent.id, session.user.id, { - updatedAt: new Date(), - } as any); - } - }, - onError: handleError, - originalMessages: messages, - }); + } else { + await chatRepository.upsertMessage({ + threadId: thread!.id, + role: message.role, + parts: message.parts.map(convertToSavePart), + id: message.id, + }); + await chatRepository.upsertMessage({ + threadId: thread!.id, + role: responseMessage.role, + id: responseMessage.id, + parts: responseMessage.parts.map(convertToSavePart), + metadata, + }); + } - return createUIMessageStreamResponse({ - stream, - }); - } catch (error: any) { - logger.error(error); - return Response.json({ message: error.message }, { status: 500 }); - } + if (agent) { + agentRepository.updateAgent(agent.id, session.user.id, { + updatedAt: new Date(), + } as any); + } + }, + onError: handleError, + originalMessages: messages, + }); + + return createUIMessageStreamResponse({ + stream, + }); } 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/app/api/workflow/schedules/dispatch/route.ts b/src/app/api/workflow/schedules/dispatch/route.ts new file mode 100644 index 000000000..c27f5ce8c --- /dev/null +++ b/src/app/api/workflow/schedules/dispatch/route.ts @@ -0,0 +1,59 @@ +import { dispatchWorkflowSchedules } from "lib/ai/workflow/workflow-scheduler"; + +const HEADER_SECRET_KEYS = [ + "authorization", + "x-workflow-scheduler-secret", + "x-cron-secret", +]; + +function extractSecretToken(request: Request): string | null { + for (const headerKey of HEADER_SECRET_KEYS) { + const headerValue = request.headers.get(headerKey); + if (!headerValue) continue; + if (headerKey === "authorization") { + const [scheme, token] = headerValue.split(" "); + if (scheme?.toLowerCase() === "bearer" && token) { + return token; + } + } else if (headerValue.trim().length) { + return headerValue.trim(); + } + } + return null; +} + +export async function POST(request: Request) { + const secret = process.env.WORKFLOW_SCHEDULER_SECRET; + if (!secret) { + return new Response("Scheduler secret is not configured", { + status: 500, + }); + } + + const providedSecret = extractSecretToken(request); + if (providedSecret !== secret) { + return new Response("Unauthorized", { status: 401 }); + } + + let body: { limit?: number; dryRun?: boolean } = {}; + if (request.headers.get("content-type")?.includes("application/json")) { + try { + body = (await request.json()) as typeof body; + } catch { + body = {}; + } + } + + const limit = + typeof body.limit === "number" && Number.isFinite(body.limit) + ? Math.max(1, Math.min(25, Math.floor(body.limit))) + : undefined; + const dryRun = typeof body.dryRun === "boolean" ? body.dryRun : false; + + const result = await dispatchWorkflowSchedules({ limit, dryRun }); + + return Response.json({ + ok: true, + result, + }); +} diff --git a/src/app/store/index.ts b/src/app/store/index.ts index 44a7ba541..b82c8fcdb 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -88,6 +88,7 @@ const initialState: AppState = { allowedAppDefaultToolkit: [ AppDefaultToolkit.Code, AppDefaultToolkit.Visualization, + AppDefaultToolkit.Automation, ], toolPresets: [], chatModel: undefined, diff --git a/src/components/chat-bot.tsx b/src/components/chat-bot.tsx index ae9efe47a..92243387a 100644 --- a/src/components/chat-bot.tsx +++ b/src/components/chat-bot.tsx @@ -186,6 +186,13 @@ export default function ChatBot({ threadId, initialMessages }: Props) { [], ); + let clientTimezone: string | undefined; + try { + clientTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + clientTimezone = undefined; + } + const sanitizedLastMessage = { ...lastMessage, parts: lastMessage.parts.filter((p: any) => p?.type !== "source-url"), @@ -213,6 +220,7 @@ export default function ChatBot({ threadId, initialMessages }: Props) { model: latestRef.current.threadImageToolModel[threadId], }, attachments, + clientTimezone, }; return { body: requestBody }; }, diff --git a/src/components/chat-mention-input.tsx b/src/components/chat-mention-input.tsx index e11382c31..5b2945a20 100644 --- a/src/components/chat-mention-input.tsx +++ b/src/components/chat-mention-input.tsx @@ -386,6 +386,11 @@ export function ChatMentionInputSuggestion({ label = "python-execution"; description = "Execute simple python code"; break; + case DefaultToolName.Pulse: + label = "pulse"; + description = + "Create a Pulse automation that schedules web research updates"; + break; } return { id: toolName, diff --git a/src/components/default-tool-icon.tsx b/src/components/default-tool-icon.tsx index 6676e4d13..73faf2b01 100644 --- a/src/components/default-tool-icon.tsx +++ b/src/components/default-tool-icon.tsx @@ -10,6 +10,7 @@ import { CodeIcon, HammerIcon, TableOfContents, + SparklesIcon, } from "lucide-react"; import { useMemo } from "react"; @@ -57,6 +58,11 @@ export function DefaultToolIcon({ if (name === DefaultToolName.PythonExecution) { return ; } + if (name === DefaultToolName.Pulse) { + return ( + + ); + } return ; }, [name]); } diff --git a/src/components/message-parts.tsx b/src/components/message-parts.tsx index fedec6317..ccecaa8cc 100644 --- a/src/components/message-parts.tsx +++ b/src/components/message-parts.tsx @@ -726,6 +726,15 @@ const ImageGeneratorToolInvocation = dynamic( }, ); +const PulseToolInvocation = dynamic( + () => + import("./tool-invocation/pulse").then((mod) => mod.PulseToolInvocation), + { + ssr: false, + loading, + }, +); + // Local shortcuts for tool invocation approval/rejection const approveToolInvocationShortcut: Shortcut = { description: "approveToolInvocation", @@ -876,6 +885,10 @@ export const ToolMessagePart = memo( return ; } + if (toolName === DefaultToolName.Pulse) { + return ; + } + if (toolName === ImageToolName) { return ; } diff --git a/src/components/tool-invocation/pulse.tsx b/src/components/tool-invocation/pulse.tsx new file mode 100644 index 000000000..f481aade6 --- /dev/null +++ b/src/components/tool-invocation/pulse.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { ToolUIPart } from "ai"; +import { DefaultToolName } from "lib/ai/tools"; +import equal from "lib/equal"; +import { toAny } from "lib/utils"; +import { AlertTriangleIcon, SparklesIcon } from "lucide-react"; +import { memo, useMemo, type ReactNode } from "react"; +import { Alert, AlertDescription, AlertTitle } from "ui/alert"; +import { Badge } from "ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "ui/card"; +import { Separator } from "ui/separator"; +import { Skeleton } from "ui/skeleton"; +import { TextShimmer } from "ui/text-shimmer"; + +import { DefaultToolIcon } from "../default-tool-icon"; + +type PulseToolResult = { + workflowId: string; + scheduleNodeId: string; + topic: string; + cron: string; + timezone: string; + nextRunAt: string | null; + numResults: number; + summaryInstructions?: string | null; + message?: string | null; +}; + +interface PulseToolInvocationProps { + part: ToolUIPart; +} + +function PurePulseToolInvocation({ part }: PulseToolInvocationProps) { + const result = useMemo(() => { + if (!part.state.startsWith("output")) return null; + return part.output as PulseToolResult; + }, [part.output, part.state]); + + if (!part.state.startsWith("output")) { + return ; + } + + if (part.state === "output-error" || !result) { + return ( + + + Pulse couldn’t be created + + {part.errorText || + result?.message || + "Something went wrong while setting up your Pulse. Please try again."} + + + ); + } + + const nextRunDate = result.nextRunAt ? new Date(result.nextRunAt) : null; + const formattedNextRun = nextRunDate + ? nextRunDate.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + timeZone: result.timezone, + }) + : null; + + return ( + + +
+ +
+ New Pulse created + + {result.message ?? + "I’ll keep an eye on this and pulse you with updates here."} + +
+
+
+ +
+ {result.topic} + + {result.timezone} + + {result.numResults} + + {formattedNextRun ? ( +
+ {formattedNextRun} + + {nextRunDate?.toLocaleString(undefined, { + timeZoneName: "short", + })} + +
+ ) : ( + "Scheduling first pulse…" + )} +
+
+ + {result.summaryInstructions && ( +
+ + How I’ll summarize + +

+ {result.summaryInstructions} +

+
+ )} + + + + What happens next + +
    +
  1. I’ll regularly search the web for your topic.
  2. +
  3. I’ll turn what I find into a short, clear summary.
  4. +
  5. + Whenever there’s something new, I’ll pulse you in this chat. +
  6. +
+
+
+
+ + +

+ You can manage all your Pulses anytime in Settings → Workflows. +

+
+
+ ); +} + +function areEqual( + { part: prevPart }: PulseToolInvocationProps, + { part: nextPart }: PulseToolInvocationProps, +) { + if (prevPart.state != nextPart.state) return false; + if (!equal(prevPart.input, nextPart.input)) return false; + if ( + prevPart.state.startsWith("output") && + !equal(prevPart.output, toAny(nextPart).output) + ) + return false; + return true; +} + +export const PulseToolInvocation = memo(PurePulseToolInvocation, areEqual); + +function Detail({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function PulseLoadingCard() { + return ( + + +
+ +
+ + Creating your Pulse… + + + I’m setting things up so I can watch this for you. + +
+
+
+ + + + + +
+ ); +} diff --git a/src/components/tool-select-dropdown.tsx b/src/components/tool-select-dropdown.tsx index 44f0ee2b9..1cc29f8a6 100644 --- a/src/components/tool-select-dropdown.tsx +++ b/src/components/tool-select-dropdown.tsx @@ -19,6 +19,7 @@ import { Package, Plus, ShieldAlertIcon, + SparklesIcon, Waypoints, Wrench, WrenchIcon, @@ -887,6 +888,9 @@ function AppDefaultToolKitSelector() { case AppDefaultToolkit.Code: icon = CodeIcon; break; + case AppDefaultToolkit.Automation: + icon = SparklesIcon; + break; } return { label, diff --git a/src/components/workflow/default-node.tsx b/src/components/workflow/default-node.tsx index fcd9248f6..82b01d90c 100644 --- a/src/components/workflow/default-node.tsx +++ b/src/components/workflow/default-node.tsx @@ -25,6 +25,8 @@ 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"; +import { SchedulerNodeStack } from "./node-config/scheduler-node-config"; type Props = NodeProps; @@ -106,7 +108,9 @@ export const DefaultNode = memo(function DefaultNode({ )} >
- {![NodeKind.Note, NodeKind.Input].includes(data.kind) && ( + {![NodeKind.Note, NodeKind.Input, NodeKind.Scheduler].includes( + data.kind, + ) && (
{data.name}
- {![NodeKind.Note, NodeKind.Output, NodeKind.Condition].includes( - data.kind, - ) && ( + {![ + NodeKind.Note, + NodeKind.Output, + NodeKind.Condition, + NodeKind.Scheduler, + ].includes(data.kind) && ( update()} @@ -197,6 +204,12 @@ export const DefaultNode = memo(function DefaultNode({ )} {data.kind === NodeKind.Tool && } {data.kind === NodeKind.Http && } + {data.kind === NodeKind.ReplyInThread && ( + + )} + {data.kind === NodeKind.Scheduler && ( + + )} {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-config/scheduler-node-config.tsx b/src/components/workflow/node-config/scheduler-node-config.tsx new file mode 100644 index 000000000..a4d510a91 --- /dev/null +++ b/src/components/workflow/node-config/scheduler-node-config.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { memo, useCallback, useMemo, useState } from "react"; +import { SchedulerNodeData } from "lib/ai/workflow/workflow.interface"; +import { useReactFlow } from "@xyflow/react"; +import { Label } from "ui/label"; +import { Input } from "ui/input"; +import { Textarea } from "ui/textarea"; +import { Switch } from "ui/switch"; +import { useTranslations } from "next-intl"; +import { InfoIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { cn } from "lib/utils"; + +const COMMON_TIMEZONES = [ + "UTC", + "America/New_York", + "America/Los_Angeles", + "Europe/London", + "Europe/Berlin", + "Asia/Singapore", + "Asia/Tokyo", + "Australia/Sydney", +]; + +export const SchedulerNodeConfig = memo(function SchedulerNodeConfig({ + data, +}: { + data: SchedulerNodeData; +}) { + const t = useTranslations(); + const { updateNodeData } = useReactFlow(); + const [payloadText, setPayloadText] = useState(() => + JSON.stringify(data.payload ?? {}, null, 2), + ); + const [payloadError, setPayloadError] = useState(null); + + const cronHelper = useMemo(() => { + return ( + t("Workflow.schedulerCronHelper") || + "Use standard 5-field cron syntax. Examples: '0 * * * *' or '0 9 * * MON'." + ); + }, [t]); + + const timezoneHelper = useMemo(() => { + return ( + t("Workflow.schedulerTimezoneHelper") || + "Use an IANA timezone like 'UTC' or 'America/New_York'." + ); + }, [t]); + + const payloadHelper = useMemo(() => { + return ( + t("Workflow.schedulerPayloadDescription") || + "JSON payload supplied as workflow input when the schedule triggers." + ); + }, [t]); + + const handleCronChange = useCallback( + (cron: string) => { + updateNodeData(data.id, { cron }); + }, + [data.id, updateNodeData], + ); + + const handleTimezoneChange = useCallback( + (timezone: string) => { + updateNodeData(data.id, { timezone }); + }, + [data.id, updateNodeData], + ); + + const handleEnabledChange = useCallback( + (enabled: boolean) => { + updateNodeData(data.id, { enabled }); + }, + [data.id, updateNodeData], + ); + + const handlePayloadChange = useCallback( + (value: string) => { + setPayloadText(value); + if (!value.trim()) { + updateNodeData(data.id, { payload: {} }); + setPayloadError(null); + return; + } + + try { + const parsed = JSON.parse(value); + setPayloadError(null); + updateNodeData(data.id, { payload: parsed }); + } catch { + setPayloadError(t("Workflow.schedulerInvalidJson")); + } + }, + [data.id, t, updateNodeData], + ); + + return ( +
+
+
+ + + + + + + {cronHelper} + + +
+ handleCronChange(event.target.value)} + /> + + {t("Workflow.schedulerCronDocs") ?? "Open crontab.guru"} + +
+ +
+
+ + + + + + + {timezoneHelper} + + +
+ handleTimezoneChange(event.target.value)} + /> + + {COMMON_TIMEZONES.map((zone) => ( + +
+ +
+
+ +

+ {t("Workflow.schedulerEnabledDescription") ?? + "Paused schedules will not run."} +

+
+ +
+ +
+
+ + + + + + + {payloadHelper} + + +
+