From b2a450ca2f71da97ee7516f09740c0bda17a9ca1 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 14 Sep 2026 13:25:56 +0200 Subject: [PATCH] feat(core): Show the app preview when the agent says so, stream agent replies as Markdown - `apps show-preview` lifts the preview hold during the creating run; the run end stays the fallback - `AgentChat.text()` throws `agent_suspended` when the turn ends on an approval request - `markdown` catalog component renders agent replies with raw HTML escaped; `markdown-it` joins the template - Skill docs put streaming first and forbid `v-html` on agent text - Theme `mode` defaults to `system` - Bind approval card shows one row per binding --- .../__tests__/instance-ai.schema.test.ts | 96 ++++++----- .../@n8n/api-types/src/schemas/app.schema.ts | 6 +- .../src/schemas/instance-ai.schema.ts | 6 +- .../@n8n/app-sdk/src/__tests__/agents.test.ts | 18 +++ packages/@n8n/app-sdk/src/index.ts | 14 +- .../instance-ai/skills/app-builder/SKILL.md | 20 ++- .../component-registry/markdown/Markdown.vue | 39 +++++ .../component-registry/markdown/index.ts | 2 + .../component-registry/markdown/markdown.ts | 16 ++ .../skills/app-builder/references/app-sdk.md | 24 +-- .../app-builder/references/design-system.md | 1 + .../app-builder/templates/vue/AI_RULES.md | 2 +- .../templates/vue/package-lock.json | 112 +++++++++++++ .../app-builder/templates/vue/package.json | 4 +- .../src/stream/__tests__/map-chunk.test.ts | 8 +- .../@n8n/instance-ai/src/stream/map-chunk.ts | 9 +- .../src/tools/__tests__/apps.tool.test.ts | 109 ++++++++----- .../@n8n/instance-ai/src/tools/apps.tool.ts | 87 ++++++---- .../__tests__/AppBindingApproval.test.ts | 54 +++++-- .../InstanceAiConfirmationPanel.test.ts | 20 +-- .../__tests__/canvasPreview.utils.test.ts | 24 +++ .../ai/instanceAi/canvasPreview.utils.ts | 11 ++ .../components/AppBindingApproval.vue | 149 ++++++++++-------- .../components/InstanceAiAppPreview.vue | 27 ++-- .../InstanceAiConfirmationPanel.vue | 4 +- .../__tests__/InstanceAiAppPreview.test.ts | 71 ++++++++- 26 files changed, 691 insertions(+), 242 deletions(-) create mode 100644 packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/Markdown.vue create mode 100644 packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/index.ts create mode 100644 packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/markdown.ts diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts index 525bf693c3f..0948072053e 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts @@ -363,15 +363,17 @@ describe('confirmationRequestPayloadSchema', () => { it('preserves the app binding details of a workflow bind approval', () => { const payload = makeConfirmation({ - appBinding: { - kind: 'workflow', - appId: 'app-1', - appName: 'Runner', - appNamespace: 'runner', - workflowId: 'wf-1', - workflowName: 'Echo', - key: 'submit', - }, + appBindings: [ + { + kind: 'workflow', + appId: 'app-1', + appName: 'Runner', + appNamespace: 'runner', + workflowId: 'wf-1', + workflowName: 'Echo', + key: 'submit', + }, + ], }); expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); @@ -379,17 +381,19 @@ describe('confirmationRequestPayloadSchema', () => { it('preserves the app binding details of a data table bind approval', () => { const payload = makeConfirmation({ - appBinding: { - kind: 'dataTable', - appId: 'app-1', - appName: 'Board', - appNamespace: 'board', - dataTableId: 'dt-1', - dataTableName: 'Tasks', - key: 'tasks', - permissions: ['read', 'write'], - projectId: 'proj-1', - }, + appBindings: [ + { + kind: 'dataTable', + appId: 'app-1', + appName: 'Board', + appNamespace: 'board', + dataTableId: 'dt-1', + dataTableName: 'Tasks', + key: 'tasks', + permissions: ['read', 'write'], + projectId: 'proj-1', + }, + ], }); expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); @@ -397,34 +401,46 @@ describe('confirmationRequestPayloadSchema', () => { it('preserves the app binding details of an agent bind approval', () => { const payload = makeConfirmation({ - appBinding: { - kind: 'agent', - appId: 'app-1', - appName: 'Helpdesk', - appNamespace: 'helpdesk', - agentId: 'agent-1', - agentName: 'Support', - key: 'support', - permissions: ['chat', 'history'], - published: false, - projectId: 'proj-1', - }, + appBindings: [ + { + kind: 'agent', + appId: 'app-1', + appName: 'Helpdesk', + appNamespace: 'helpdesk', + agentId: 'agent-1', + agentName: 'Support', + key: 'support', + permissions: ['chat', 'history'], + published: false, + projectId: 'proj-1', + }, + ], }); expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); }); + it('rejects an empty app bindings list', () => { + const result = confirmationRequestPayloadSchema.safeParse( + makeConfirmation({ appBindings: [] }), + ); + + expect(result.success).toBe(false); + }); + it('rejects an app binding without a kind', () => { const result = confirmationRequestPayloadSchema.safeParse( makeConfirmation({ - appBinding: { - appId: 'app-1', - appName: 'Runner', - appNamespace: 'runner', - workflowId: 'wf-1', - workflowName: 'Echo', - key: 'submit', - } as unknown as InstanceAiConfirmationRequestPayload['appBinding'], + appBindings: [ + { + appId: 'app-1', + appName: 'Runner', + appNamespace: 'runner', + workflowId: 'wf-1', + workflowName: 'Echo', + key: 'submit', + }, + ] as unknown as InstanceAiConfirmationRequestPayload['appBindings'], }), ); diff --git a/packages/@n8n/api-types/src/schemas/app.schema.ts b/packages/@n8n/api-types/src/schemas/app.schema.ts index 2c3c469152f..fcf4e1a5ca7 100644 --- a/packages/@n8n/api-types/src/schemas/app.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app.schema.ts @@ -34,7 +34,11 @@ export const appThemeModeSchema = z.enum(['light', 'dark', 'system']); // What the Theme tab and the `apps` tool choose from. The server turns these // into CSS variables so the contrast, tint and spacing maths lives in one place. export const appThemeSettingsSchema = z.object({ - mode: appThemeModeSchema, + mode: appThemeModeSchema + .default('system') + .describe( + '"system" follows the visitor\'s OS setting; pick "dark" only when the look asks for it', + ), primary: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a 6-digit hex color, e.g. "#ff6900"'), /** Corner radius in px; the template default is 4. */ radius: z.number().int().min(0).max(32).optional(), diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index b137e333e54..3a8e543d8d2 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -853,7 +853,9 @@ export const confirmationRequestPayloadSchema = z.object({ webSearch: webSearchMetaSchema .optional() .describe('When present, renders web-search approval UI instead of generic confirm'), - appBinding: appBindingMetaSchema + appBindings: z + .array(appBindingMetaSchema) + .min(1) .optional() .describe('When present, renders the app binding approval UI instead of generic confirm'), appBlueprint: appBlueprintSchema @@ -922,7 +924,7 @@ export function isDisplayableConfirmationRequest( if (hasItems(payload.setupRequests)) return true; if (hasItems(payload.credentialRequests)) return true; if (payload.domainAccess) return true; - if (payload.appBinding) return true; + if (hasItems(payload.appBindings)) return true; if (payload.channelConfig) return true; if (payload.mcpConnectRequest) return true; diff --git a/packages/@n8n/app-sdk/src/__tests__/agents.test.ts b/packages/@n8n/app-sdk/src/__tests__/agents.test.ts index f09a20ab0a3..60237278abc 100644 --- a/packages/@n8n/app-sdk/src/__tests__/agents.test.ts +++ b/packages/@n8n/app-sdk/src/__tests__/agents.test.ts @@ -125,6 +125,24 @@ describe('agents', () => { }); }); + it('text() throws agent_suspended with the payload when the turn ends on an approval request', async () => { + const payload = { toolCallId: 'call-1', runId: 'run-1', toolName: 'send_email', input: {} }; + fetchMock.mockResolvedValue( + sseResponse([ + dataLine({ type: 'text-delta', id: 't', delta: 'Sure, sending' }), + dataLine({ type: 'tool-call-suspended', payload }), + ]), + ); + + const error = await client() + .agents.support.chat('hi') + .text() + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(N8nAppError); + expect(error).toMatchObject({ status: 200, code: 'agent_suspended', issues: payload }); + }); + it('cancels the response body when the consumer stops before the stream ends', async () => { const cancel = vi.fn(); const encoder = new TextEncoder(); diff --git a/packages/@n8n/app-sdk/src/index.ts b/packages/@n8n/app-sdk/src/index.ts index edcace95bb0..fe95812c5da 100644 --- a/packages/@n8n/app-sdk/src/index.ts +++ b/packages/@n8n/app-sdk/src/index.ts @@ -197,7 +197,11 @@ export interface AgentChatOptions { /** One turn of the agent. The request starts on the first read; a second pass yields nothing. */ export interface AgentChat extends AsyncIterable { - /** Drains the stream and returns the `text-delta` deltas joined; an `error` event throws `N8nAppError`. */ + /** + * Drains the stream and returns the `text-delta` deltas joined. Throws `N8nAppError` on an + * `error` event, or with code `agent_suspended` when the agent asked for approval instead of + * answering: only iterating the stream can see and resume the suspension. + */ text(): Promise; } @@ -397,6 +401,14 @@ function agentChat(start: () => Promise): AgentChat { if (event.type === 'error') { throw new N8nAppError(200, event.errorCode ?? 'execution_failed', event.message); } + if (event.type === 'tool-call-suspended') { + throw new N8nAppError( + 200, + 'agent_suspended', + `The agent is waiting for approval of "${event.payload.toolName}"; iterate the chat stream to show and resume it`, + event.payload, + ); + } } return text; }, diff --git a/packages/@n8n/instance-ai/skills/app-builder/SKILL.md b/packages/@n8n/instance-ai/skills/app-builder/SKILL.md index ac5d25bd633..c9744d2b63c 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/app-builder/SKILL.md @@ -55,8 +55,9 @@ the conversation is already bound to an app. that must survive a reload (`data-tables(action="list")`, or one you will create); `agent`: a published agent (`agents(action="list")`). Empty when the data stays in the browser. - - `theme`: `primary` hex, `mode`, and where the look asks for it `radius` - (px), `font`, `density` (`compact`|`comfortable`|`spacious`) and `tone` + - `theme`: `primary` hex, and where the look asks for it `mode` (default + `system`, which follows the visitor's OS), `radius` (px), `font`, + `density` (`compact`|`comfortable`|`spacious`) and `tone` (`neutral`|`tinted`). Playful → warm primary, large radius, spacious, tinted; minimal → gray or one cool primary, small radius, neutral; bold → saturated primary, tinted, dark mode. @@ -70,6 +71,10 @@ the conversation is already bound to an app. values; `bind` each connection (creating a missing data table first); write the pages and routes; write `BLUEPRINT.md` at the app root (name, summary, pages, connections, look) so a later conversation can read it. + The preview stays hidden during this run until you call + `apps(action="show-preview", appId)`. Call it as soon as your own pages + have replaced the template's placeholder content, so the user watches the + rest take shape. The bar is "nothing generic left on screen", not "done". ## The loop @@ -201,10 +206,13 @@ for every bound key. Details of the types, warnings and `denied` reasons: `agents(action="list")` shows the ids; the agent must be published or `chat` fails with `agent_not_published`. `apps(action="bind", appId, bindings=[{ key: "support", kind: "agent", agentId, permissions: ["chat", "history"] }])`, then -`n8n.agents.support.chat(message)` in the app. Visitors are anonymous, get -their own session and answer the agent's approval cards themselves: -`references/app-sdk.md` ("Agents") describes the chat loop, the approval -cards and `messages()` on load. +`n8n.agents.support.chat(message)` in the app. A chat UI iterates the stream +and appends each `text-delta` as it arrives; `.text()` is for a one-shot +answer only. Agents reply in Markdown: add the `markdown` catalog component +and render replies with ``, never `v-html`. Visitors are +anonymous, get their own session and answer the agent's approval cards +themselves: `references/app-sdk.md` ("Agents") describes the chat loop, the +approval cards and `messages()` on load. ## Template diff --git a/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/Markdown.vue b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/Markdown.vue new file mode 100644 index 00000000000..234d5f08d83 --- /dev/null +++ b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/Markdown.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/index.ts b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/index.ts new file mode 100644 index 00000000000..66038b4b716 --- /dev/null +++ b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/index.ts @@ -0,0 +1,2 @@ +export { default as Markdown } from './Markdown.vue'; +export { renderMarkdown } from './markdown'; diff --git a/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/markdown.ts b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/markdown.ts new file mode 100644 index 00000000000..944e1d8bc09 --- /dev/null +++ b/packages/@n8n/instance-ai/skills/app-builder/component-registry/markdown/markdown.ts @@ -0,0 +1,16 @@ +import MarkdownIt from 'markdown-it'; + +// `html: false` escapes raw HTML in the source, so agent output is safe to render. +const md = new MarkdownIt({ html: false, linkify: true, breaks: true }); + +const renderLink = md.renderer.rules.link_open ?? md.renderer.renderToken.bind(md.renderer); +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + tokens[idx].attrSet('target', '_blank'); + tokens[idx].attrSet('rel', 'noopener noreferrer'); + return renderLink(tokens, idx, options, env, self); +}; + +/** Markdown to sanitized HTML. `inline` skips the wrapping `

` for a single line. */ +export function renderMarkdown(source: string, inline = false): string { + return inline ? md.renderInline(source) : md.render(source); +} diff --git a/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md b/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md index de68a2b393c..63690fff66e 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md +++ b/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md @@ -185,12 +185,10 @@ A filter is `{ type?: 'and' | 'or', filters: [{ columnName, condition?, value }] import { n8n } from '@n8n/app-sdk'; import type { ToolSuspendedPayload } from '@n8n/app-sdk'; -// One turn. The request starts on the first read; `text()` drains the stream -// and joins the `text-delta` events. -const reply = await n8n.agents.support.chat('How do I reset my password?').text(); - -// Streaming. Render the text as it arrives and keep the suspension, if any. -// `reply`, `error` and `pending` are the app's own reactive state. +// A chat UI: one turn, streamed. The request starts on the first read. Append +// each delta as it arrives and keep the suspension, if any. `reply`, `error` +// and `pending` are the app's own reactive state; render `reply` with +// `` from the catalog, since agents answer in Markdown. let pending: ToolSuspendedPayload | undefined; for await (const event of n8n.agents.support.chat(message)) { if (event.type === 'text-delta') reply.value += event.delta; @@ -198,6 +196,11 @@ for await (const event of n8n.agents.support.chat(message)) { if (event.type === 'error') error.value = event.message; } +// A one-shot answer with no UI to stream into (a summary, a label): `text()` +// drains the stream and joins the `text-delta` events. It throws when the +// agent asks for approval instead of answering, so a chat UI streams instead. +const summary = await n8n.agents.support.chat('Summarize this ticket: …').text(); + // The agent asked for approval: render a card with Approve / Deny and send the // visitor's answer. `approved` is the button they clicked. if (pending && isApproval(pending.input)) { @@ -218,10 +221,11 @@ const { messages, openSuspensions } = await n8n.agents.support.messages(); - `chat(message, opts?)` → `AgentChat`: an `AsyncIterable` with `text(): Promise`. One `AgentChat` is one request: iterate it - or call `text()`, not both. `text()` throws `N8nAppError` (`status: 200`, - `code` from the event or `execution_failed`) when the stream carries an - `error` event, so a failed turn never reads as an empty reply. `opts`: - `sessionId` (default `sessionId()`), `signal`. + or call `text()`, not both. `text()` throws `N8nAppError` (`status: 200`) + with the event's code or `execution_failed` when the stream carries an + `error` event, and with `agent_suspended` when the turn ends on + `tool-call-suspended`, so a failed or paused turn never reads as an empty + reply. `opts`: `sessionId` (default `sessionId()`), `signal`. - The events are the same as the agent editor's stream: `text-start` / `text-delta` / `text-end` (the reply, `delta` per event), `reasoning-*`, `tool-input-*`, `tool-call` / `tool-execution-*` / `tool-result` (tool diff --git a/packages/@n8n/instance-ai/skills/app-builder/references/design-system.md b/packages/@n8n/instance-ai/skills/app-builder/references/design-system.md index 3bda2631362..9f6b1d897e0 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/references/design-system.md +++ b/packages/@n8n/instance-ai/skills/app-builder/references/design-system.md @@ -60,6 +60,7 @@ then import each as `import { X } from '@/components/ui/'`: | Switch | `Switch` | `v-model` a boolean ref. | | Checkbox | `Checkbox` | `v-model` a boolean ref. | | Tooltip | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` | `TooltipProvider` is a plain passthrough (Ark UI needs no shared context) — wrap the tree in one only to match other libraries' convention if you want to. | +| Markdown | `Markdown`, `renderMarkdown` | `` renders Markdown (agent replies, rich text) as styled, sanitized HTML: raw HTML in the source is escaped, links open in a new tab. `inline` drops the wrapping paragraph. `renderMarkdown(source, inline?)` returns the HTML string for the rare case a component needs it. Never `v-html` agent text. | | DropdownMenu | `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuGroup`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuSub`, `DropdownMenuSubTrigger` | For a submenu, nest a `DropdownMenuSub` inside the parent's `DropdownMenuContent`, with a `DropdownMenuSubTrigger` and its own `DropdownMenuContent` inside it. | Every `Item`/`Trigger`/`CheckboxItem`/`RadioItem` that identifies one entry in diff --git a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md index 07d7fb2f170..687dc47f60c 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md +++ b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md @@ -6,7 +6,7 @@ Stack: Vite, Vue 3, TypeScript, vue-router, Tailwind v4, this skill's own compon - Do not start a dev server; n8n runs one for the live preview, and dependencies are installed at `create`. Every edit appears in the preview by itself (hot module reload). Never run a build to check your work. Never tell the user to publish, to open `/apps//` or to build to see a change: the preview beside the chat already shows it. Call `apps` (`action: "publish"`) only when the user asks to publish, deploy or share the app: the user confirms, then n8n builds the current source and updates `/apps//`. Preview errors arrive as context on the user's next message; fix them before anything else. - The build sandbox has 512 MiB of memory. Publishing runs `npm run build` (`vite build` only) and serves `dist/`; keep type checking out of the build script. A new dependency needs `npm install` in the app directory by you. - Workflows: the app calls n8n workflows only through `@n8n/app-sdk` (`import { n8n, N8nAppError } from '@n8n/app-sdk'`, `await n8n.workflows.run('', input)`) and only for keys bound with `apps(action="bind")`. Bind first; the `bind` result lists each key with its input and output schema. Data tables work the same way: bind with `kind: "dataTable"`, then `n8n.tables..list/insert/update/delete`; the `bind` result lists the columns. Store data that must survive a reload in a data table, not in `localStorage` or an in-memory array. `vendor/n8n-app-sdk.tgz` and `src/n8n-bindings.d.ts` are generated; do not edit them. Never call `/rest`, `/webhook` or `/apps/*/api` by hand. All apps are public: the runtime API is callable from the app's own page only (CORS), but like a public webhook, anyone who can reach the instance can call a bound workflow, table or agent, so bind only ones that may be public; `result.principal` is `null`. -- Agents: the app chats with an n8n agent only through `n8n.agents.` (`chat(message)` streams `AgentSseEvent`s, `.text()` joins the reply; `resume` answers a `tool-call-suspended` card; `messages()` restores the visitor's history) and only for keys bound with `kind: "agent"`. The agent must be published, or `chat` fails with `agent_not_published`. Visitors are anonymous: each gets their own session (`sessionId()`; the conversation survives a reload in the same tab), and they answer the agent's approval requests themselves, so render every `tool-call-suspended` with `input.type === "approval"` as an Approve / Deny card and call `resume({ runId, toolCallId, resumeData: { approved } })`. +- Agents: the app chats with an n8n agent only through `n8n.agents.` (`chat(message)` streams `AgentSseEvent`s; `resume` answers a `tool-call-suspended` card; `messages()` restores the visitor's history) and only for keys bound with `kind: "agent"`. A chat UI iterates the stream (`for await`) and appends each `text-delta` as it arrives, so the visitor sees the reply grow; `.text()` waits for the whole turn and throws if the agent asks for approval, so use it only for a one-shot answer with no approval possible. Agents reply in Markdown: render it with `Markdown` from the catalog (`apps(action: "add-component", components: ["markdown"])`), never with `v-html`. The agent must be published, or `chat` fails with `agent_not_published`. Visitors are anonymous: each gets their own session (`sessionId()`; the conversation survives a reload in the same tab), and they answer the agent's approval requests themselves, so render every `tool-call-suspended` with `input.type === "approval"` as an Approve / Deny card and call `resume({ runId, toolCallId, resumeData: { approved } })`. - Build UI from the components in `src/components/ui/`, e.g. `import { Button } from '@/components/ui/button'`. Need components that aren't there yet? Call `apps(action: "add-component", components: ["", …])` once for all of them — do not hand-write a component the catalog already provides. - Style everything with Tailwind utility classes reading the theme's CSS variables (`bg-primary`, `text-muted-foreground`, `rounded-lg`). No hex colors, no inline styles, no `dark:` variants (dark mode comes from the `.dark` class the theme mode sets). - For interactive behavior a component doesn't already cover, use `@ark-ui/vue` primitives directly and style them with utilities and `data-[state=…]:` variants. diff --git a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package-lock.json b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package-lock.json index 05c14caed3d..9b8f277e394 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package-lock.json +++ b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package-lock.json @@ -12,12 +12,14 @@ "@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "markdown-it": "^14.3.2", "tailwind-merge": "^3.6.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", + "@types/markdown-it": "^14.2.0", "@vitejs/plugin-vue": "^5.2.4", "tailwindcss": "^4.1.0", "tw-animate-css": "^1.4.0", @@ -1467,6 +1469,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -2627,6 +2654,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3095,6 +3128,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3104,6 +3156,51 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3220,6 +3317,15 @@ "proxy-compare": "^3.0.0" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -3353,6 +3459,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uqr": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", diff --git a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package.json b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package.json index 7dd84dd38c5..344d0eaf605 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package.json +++ b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/package.json @@ -8,16 +8,18 @@ "typecheck": "vue-tsc -b" }, "dependencies": { - "@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz", "@ark-ui/vue": "^5.39.1", + "@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "markdown-it": "^14.3.2", "tailwind-merge": "^3.6.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", + "@types/markdown-it": "^14.2.0", "@vitejs/plugin-vue": "^5.2.4", "tailwindcss": "^4.1.0", "tw-animate-css": "^1.4.0", diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts index de6f393bcfb..f34a0bf0377 100644 --- a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts +++ b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts @@ -571,14 +571,14 @@ describe('mapAgentChunkToEvent', () => { type: 'tool-call-suspended', toolCallId: 'tc-1', toolName: 'apps', - suspendPayload: { requestId: 'request-1', message: 'Connect', appBinding: payload }, + suspendPayload: { requestId: 'request-1', message: 'Connect', appBindings: payload }, }); - expect(suspended(appBinding)).toMatchObject({ + expect(suspended([appBinding])).toMatchObject({ type: 'confirmation-request', - payload: { message: 'Connect', appBinding }, + payload: { message: 'Connect', appBindings: [appBinding] }, }); - expect(suspended({ appId: 'app-1' })).not.toHaveProperty('payload.appBinding'); + expect(suspended([{ appId: 'app-1' }])).not.toHaveProperty('payload.appBindings'); }); it('maps confirmations with a channelConfig payload', () => { diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index aa3e66bb933..f3e10299078 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -239,7 +239,10 @@ function parseSchemaArray(value: unknown, schema: z.ZodType): T[] | undefi return parsed.length > 0 ? parsed : undefined; } -function parseSchemaRecord(value: unknown, schema: z.ZodType): T | undefined { +function parseSchemaRecord( + value: unknown, + schema: z.ZodType, +): T | undefined { if (!isRecord(value)) return undefined; const parsed = schema.safeParse(value); return parsed.success ? parsed.data : undefined; @@ -343,7 +346,7 @@ function mapSuspendedChunk( const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema); const domainAccess = parseDomainAccess(suspendPayload.domainAccess); const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema); - const appBinding = parseSchemaRecord(suspendPayload.appBinding, appBindingMetaSchema); + const appBindings = parseSchemaArray(suspendPayload.appBindings, appBindingMetaSchema); const appBlueprint = parseSchemaRecord(suspendPayload.appBlueprint, appBlueprintSchema); const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow); const credentialDestination = parseSchemaRecord( @@ -394,7 +397,7 @@ function mapSuspendedChunk( ...(inputType ? { inputType } : {}), ...(domainAccess ? { domainAccess } : {}), ...(webSearch ? { webSearch } : {}), - ...(appBinding ? { appBinding } : {}), + ...(appBindings ? { appBindings } : {}), ...(appBlueprint ? { appBlueprint } : {}), ...(credentialFlow ? { credentialFlow } : {}), ...(credentialDestination ? { credentialDestination } : {}), diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts index 673db674a1f..9c4d381f768 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts @@ -1546,8 +1546,15 @@ describe('apps tool', () => { expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); - it('suspends with plain text naming every workflow when several are bound at once', async () => { + it('suspends with one card row per workflow when several are bound at once', async () => { const context = createMockContext(); + appServiceMock(context, 'previewBindings').mockResolvedValue({ + bindings: [ + SUBMIT_BINDING, + { ...SUBMIT_BINDING, key: 'notify', workflowId: 'wf-2', name: 'Notify' }, + ], + warnings: [], + }); const suspend = vi.fn().mockResolvedValue('suspended'); const result = await runAction(context, bindInput, { resumeData: undefined, suspend }); @@ -1560,8 +1567,16 @@ describe('apps tool', () => { 'Connect workflow "Echo" (wf-2) to app "Greeter" as "notify" ' + '(callable by anyone with the app URL)', severity: 'warning', + appBindings: [ + expect.objectContaining({ kind: 'workflow', workflowId: 'wf-1', key: 'submit' }), + expect.objectContaining({ + kind: 'workflow', + workflowId: 'wf-2', + workflowName: 'Notify', + key: 'notify', + }), + ], }); - expect(appServiceMock(context, 'previewBindings')).not.toHaveBeenCalled(); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); @@ -1584,15 +1599,17 @@ describe('apps tool', () => { message: 'Connect workflow "Echo" (wf-1) to app "Greeter" as "submit" (callable by anyone with the app URL)', severity: 'warning', - appBinding: { - kind: 'workflow', - appId: 'app-1', - appName: 'Greeter', - appNamespace: 'greeter', - workflowId: 'wf-1', - workflowName: 'Echo', - key: 'submit', - }, + appBindings: [ + { + kind: 'workflow', + appId: 'app-1', + appName: 'Greeter', + appNamespace: 'greeter', + workflowId: 'wf-1', + workflowName: 'Echo', + key: 'submit', + }, + ], }); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); @@ -1611,7 +1628,7 @@ describe('apps tool', () => { { resumeData: undefined, suspend }, ); - expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBinding'); + expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBindings'); }); it('falls back to the workflow id when the workflow cannot be read', async () => { @@ -1682,22 +1699,24 @@ describe('apps tool', () => { message: 'Connect data table "Tasks" (dt-1) to app "Greeter" as "tasks" with read and write access (anyone with the app URL gets this access)', severity: 'warning', - appBinding: { - kind: 'dataTable', - appId: 'app-1', - appName: 'Greeter', - appNamespace: 'greeter', - dataTableId: 'dt-1', - dataTableName: 'Tasks', - key: 'tasks', - permissions: ['read', 'write'], - projectId: 'proj-1', - }, + appBindings: [ + { + kind: 'dataTable', + appId: 'app-1', + appName: 'Greeter', + appNamespace: 'greeter', + dataTableId: 'dt-1', + dataTableName: 'Tasks', + key: 'tasks', + permissions: ['read', 'write'], + projectId: 'proj-1', + }, + ], }); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); - it('suspends with plain text naming every table when several are bound at once', async () => { + it('falls back to plain text when one of several tables is missing', async () => { const context = createMockContext(); appServiceMock(context, 'previewBindings').mockResolvedValue({ bindings: [TASKS_BINDING, MISSING_BINDING], @@ -1799,23 +1818,25 @@ describe('apps tool', () => { 'Connect agent "Support" (agent-1) to app "Greeter" as "support" with chat and history access ' + "(anyone with the app URL gets this access and answers the agent's approval requests)", severity: 'warning', - appBinding: { - kind: 'agent', - appId: 'app-1', - appName: 'Greeter', - appNamespace: 'greeter', - agentId: 'agent-1', - agentName: 'Support', - key: 'support', - permissions: ['chat', 'history'], - published: false, - projectId: 'proj-1', - }, + appBindings: [ + { + kind: 'agent', + appId: 'app-1', + appName: 'Greeter', + appNamespace: 'greeter', + agentId: 'agent-1', + agentName: 'Support', + key: 'support', + permissions: ['chat', 'history'], + published: false, + projectId: 'proj-1', + }, + ], }); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); - it('suspends with plain text naming every agent when several are bound at once', async () => { + it('falls back to plain text when the preview resolves none of several agents', async () => { const context = withDelegate(); resolveAgentName.mockResolvedValueOnce('Support').mockResolvedValueOnce(undefined); const suspend = vi.fn().mockResolvedValue('suspended'); @@ -1832,7 +1853,6 @@ describe('apps tool', () => { { resumeData: undefined, suspend }, ); - expect(appServiceMock(context, 'previewBindings')).not.toHaveBeenCalled(); expect(suspend).toHaveBeenCalledWith({ requestId: expect.any(String), message: @@ -1850,7 +1870,7 @@ describe('apps tool', () => { await runAction(context, bindInput, { resumeData: undefined, suspend }); expect(suspend.mock.calls[0][0].message).toContain('Connect agent "agent-1" (agent-1)'); - expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBinding'); + expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBindings'); }); it('is denied when the user rejects the card', async () => { @@ -1950,6 +1970,17 @@ describe('apps tool', () => { }); }); + describe('show-preview', () => { + it('returns a marker without touching the app', async () => { + const context = createMockContext(); + + const result = await runAction(context, { action: 'show-preview' }); + + expect(result).toEqual({ appId: 'app-1', shown: true }); + expect(appServiceMock(context, 'get')).not.toHaveBeenCalled(); + }); + }); + describe('buildCheckScript', () => { it('matches the snapshot', () => { expect( diff --git a/packages/@n8n/instance-ai/src/tools/apps.tool.ts b/packages/@n8n/instance-ai/src/tools/apps.tool.ts index 3d6eed481e8..95565846e15 100644 --- a/packages/@n8n/instance-ai/src/tools/apps.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/apps.tool.ts @@ -173,11 +173,17 @@ const bindingsSchema = z.object({ appId: z.string(), }); +/** A marker for the editor: the call in the run's agent tree lifts the hold on the frame. */ +const showPreviewSchema = z.object({ + action: z.literal('show-preview'), + appId: z.string(), +}); + const confirmationSuspendSchema = z.object({ requestId: z.string(), message: z.string(), severity: instanceAiConfirmationSeveritySchema, - appBinding: appBindingMetaSchema.optional(), + appBindings: z.array(appBindingMetaSchema).min(1).optional(), }); interface ConfirmationToolContext { @@ -199,13 +205,15 @@ type BindPermission = 'bindAppWorkflow' | 'bindAppDataTable' | 'bindAppAgent'; const DEFAULT_AGENT_PERMISSIONS: AgentBinding['permissions'] = ['chat', 'history']; type UnbindInput = z.infer; type BindingsInput = z.infer; +type ShowPreviewInput = z.infer; type AppsInput = | CreateInput | PublishInput | AddComponentInput | BindInput | UnbindInput - | BindingsInput; + | BindingsInput + | ShowPreviewInput; /** Not a tool action: n8n's publish pipeline calls `handleBuild` directly. */ interface BuildInput { @@ -1071,7 +1079,7 @@ async function requireBindApproval( context: InstanceAiContext, ctx: ConfirmationToolContext, permission: BindPermission, - describe: () => Promise<{ message: string; appBinding?: AppBindingMeta }>, + describe: () => Promise, ): Promise { const mode = context.permissions?.[permission]; if (mode === 'blocked') { @@ -1093,7 +1101,22 @@ async function requireBindApproval( } type BoundApp = Omit; -type BindDescription = { message: string; appBinding?: AppBindingMeta }; +// The card shows one row per binding; a binding the preview cannot resolve (the write +// refuses it with the reason) drops the card and leaves the plain text. +type BindDescription = { message: string; appBindings?: AppBindingMeta[] }; + +function describeBindings( + bindings: AppBinding[], + described: DescribedBinding[], + is: (binding: DescribedBinding) => binding is T, + toMeta: (binding: T) => AppBindingMeta, +): Pick { + const resolved = bindings.flatMap((binding) => { + const match = described.find((candidate) => candidate.key === binding.key); + return match && is(match) ? [match] : []; + }); + return resolved.length === bindings.length ? { appBindings: resolved.map(toMeta) } : {}; +} /** The model passes one flat object per binding; the service wants the discriminated union. */ function toAppBinding(flat: FlatBinding): AppBinding | AppActionDenied { @@ -1144,22 +1167,18 @@ async function describeWorkflowBind( ); // One line: the card renders the message as plain HTML text, which folds newlines. const message = `${lines.join('; ')} (callable by anyone with the app URL)`; - // The structured card shows one binding. A multi-binding call, or a workflow the - // preview cannot resolve (the write refuses it with the reason), gets the plain text. - const [described] = - bindings.length === 1 ? (await appService.previewBindings(app.id, bindings)).bindings : []; - if (!described || !isWorkflowBinding(described)) return { message }; + const described = (await appService.previewBindings(app.id, bindings)).bindings; return { message, - appBinding: { + ...describeBindings(bindings, described, isWorkflowBinding, (binding) => ({ kind: 'workflow', appId: app.id, appName: app.name, appNamespace: app.namespace, - workflowId: described.workflowId, - workflowName: described.name, - key: described.key, - }, + workflowId: binding.workflowId, + workflowName: binding.name, + key: binding.key, + })), }; } @@ -1176,21 +1195,19 @@ async function describeDataTableBind( `Connect data table "${nameOf(key)}" (${dataTableId}) to app "${app.name}" as "${key}" with ${permissions.join(' and ')} access`, ); const message = `${lines.join('; ')} (anyone with the app URL gets this access)`; - const [only] = described; - if (bindings.length !== 1 || !only || !isDataTableBinding(only)) return { message }; return { message, - appBinding: { + ...describeBindings(bindings, described, isDataTableBinding, (binding) => ({ kind: 'dataTable', appId: app.id, appName: app.name, appNamespace: app.namespace, - dataTableId: only.dataTableId, - dataTableName: only.name, - key: only.key, - permissions: only.permissions, + dataTableId: binding.dataTableId, + dataTableName: binding.name, + key: binding.key, + permissions: binding.permissions, projectId: app.projectId, - }, + })), }; } @@ -1213,25 +1230,21 @@ async function describeAgentBind( ); // Visitors are anonymous: the app forwards the agent's approval requests to them. const message = `${lines.join('; ')} (anyone with the app URL gets this access and answers the agent's approval requests)`; - const [described] = - bindings.length === 1 - ? (await requireAppService(context).previewBindings(app.id, bindings)).bindings - : []; - if (!described || !isAgentBinding(described)) return { message }; + const described = (await requireAppService(context).previewBindings(app.id, bindings)).bindings; return { message, - appBinding: { + ...describeBindings(bindings, described, isAgentBinding, (binding) => ({ kind: 'agent', appId: app.id, appName: app.name, appNamespace: app.namespace, - agentId: described.agentId, - agentName: described.name, - key: described.key, - permissions: described.permissions, - published: described.published, + agentId: binding.agentId, + agentName: binding.name, + key: binding.key, + permissions: binding.permissions, + published: binding.published, projectId: app.projectId, - }, + })), }; } @@ -1369,6 +1382,7 @@ export function createAppsTool(context: InstanceAiContext) { bindSchema, unbindSchema, bindingsSchema, + showPreviewSchema, ]), ); @@ -1380,7 +1394,8 @@ export function createAppsTool(context: InstanceAiContext) { '`publish` asks the user to confirm, then builds the current source and updates /apps//; returns `url`, `{ denied }`, or `{ error, stage, message, log }`. ' + '`add-component` copies catalog components (references/design-system.md) into src/components/ui/; returns the names added. ' + '`bind` connects workflows (`{ key, kind: "workflow", workflowId }`), data tables (`{ key, kind: "dataTable", dataTableId, permissions }`) or published agents (`{ key, kind: "agent", agentId, permissions }`), one kind per call, after user approval; returns every binding with its types. ' + - '`unbind` removes a key; `bindings` lists them. All three rewrite src/n8n-bindings.d.ts.', + '`unbind` removes a key; `bindings` lists them. All three rewrite src/n8n-bindings.d.ts. ' + + '`show-preview` reveals the live preview during the run that creates the app. Call it once the template is gone and your own app shows, not when it is finished.', ) .input(inputSchema) .suspend(confirmationSuspendSchema) @@ -1399,6 +1414,8 @@ export function createAppsTool(context: InstanceAiContext) { return await handleUnbind(context, input, ctx.abortSignal); case 'bindings': return await handleBindings(context, input, ctx.abortSignal); + case 'show-preview': + return { appId: input.appId, shown: true }; } }) .build(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts index 68f73d91125..1e4cff61854 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts @@ -47,14 +47,14 @@ const OPTIONS: ApprovalOption[] = [ ]; const renderComponent = createComponentRenderer(AppBindingApproval, { - props: { appBinding: APP_BINDING, options: OPTIONS }, + props: { appBindings: [APP_BINDING], options: OPTIONS }, }); describe('AppBindingApproval', () => { it('shows the app, then the workflow linked in a new tab, and nothing else', () => { const { getByTestId, getByText, queryByText } = renderComponent(); - expect(getByText('Allow AI Assistant to connect a workflow to the app?')).toBeInTheDocument(); + expect(getByText('Allow n8n Assistant to connect a workflow to the app?')).toBeInTheDocument(); const app = getByTestId('instance-ai-app-binding-app'); const link = getByTestId('instance-ai-app-binding-workflow'); expect(app).toHaveTextContent('Runner'); @@ -76,10 +76,12 @@ describe('AppBindingApproval', () => { it('shows the app, the data table linked in a new tab, and the read-write access line', () => { const { getByTestId, getByText, queryByTestId, queryByText } = renderComponent({ - props: { appBinding: DATA_TABLE_BINDING }, + props: { appBindings: [DATA_TABLE_BINDING] }, }); - expect(getByText('Allow AI Assistant to connect a data table to the app?')).toBeInTheDocument(); + expect( + getByText('Allow n8n Assistant to connect a data table to the app?'), + ).toBeInTheDocument(); expect(getByTestId('instance-ai-app-binding-app')).toHaveTextContent('Board'); const link = getByTestId('instance-ai-app-binding-data-table'); expect(link).toHaveTextContent('Tasks'); @@ -92,9 +94,39 @@ describe('AppBindingApproval', () => { expect(queryByText(/tasks/)).toBeNull(); }); + it('shows one row with its own access line per data table when several are bound', () => { + const { getAllByTestId, getAllByText } = renderComponent({ + props: { + appBindings: [ + { + ...DATA_TABLE_BINDING, + key: 'sessions', + dataTableName: 'Sessions', + permissions: ['read'], + }, + { ...DATA_TABLE_BINDING, key: 'agenda', dataTableId: 'dt-2', dataTableName: 'Agenda' }, + ], + }, + }); + + expect(getAllByText('Allow n8n Assistant to connect a data table to the app?')).toHaveLength(1); + expect( + getAllByTestId('instance-ai-app-binding-app').map((el) => el.textContent?.trim()), + ).toEqual(['Board', 'Board']); + const links = getAllByTestId('instance-ai-app-binding-data-table'); + expect(links.map((el) => el.textContent?.trim())).toEqual(['Sessions', 'Agenda']); + expect(links[1]).toHaveAttribute('href', '/projects/proj-1/datatables/dt-2'); + expect( + getAllByTestId('instance-ai-app-binding-access').map((el) => el.textContent?.trim()), + ).toEqual([ + 'Anyone who can open the app can read this table.', + 'Anyone who can open the app can read and change rows in this table.', + ]); + }); + it('shows the read-only access line for a read-only data table binding', () => { const { getByTestId } = renderComponent({ - props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['read'] } }, + props: { appBindings: [{ ...DATA_TABLE_BINDING, permissions: ['read'] }] }, }); expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent( @@ -104,7 +136,7 @@ describe('AppBindingApproval', () => { it('shows the write-only access line for a data table binding without read', () => { const { getByTestId } = renderComponent({ - props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['write'] } }, + props: { appBindings: [{ ...DATA_TABLE_BINDING, permissions: ['write'] }] }, }); expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent( @@ -114,7 +146,7 @@ describe('AppBindingApproval', () => { it('emits the selected option key for a data table binding', async () => { const { getByTestId, emitted } = renderComponent({ - props: { appBinding: DATA_TABLE_BINDING }, + props: { appBindings: [DATA_TABLE_BINDING] }, }); await userEvent.click(getByTestId('opt-always-allow')); @@ -124,10 +156,10 @@ describe('AppBindingApproval', () => { it('shows the app, the agent linked in a new tab, the chat-and-history access line and the approvals line', () => { const { getByTestId, getByText, queryByTestId, queryByText } = renderComponent({ - props: { appBinding: AGENT_BINDING }, + props: { appBindings: [AGENT_BINDING] }, }); - expect(getByText('Allow AI Assistant to connect an agent to the app?')).toBeInTheDocument(); + expect(getByText('Allow n8n Assistant to connect an agent to the app?')).toBeInTheDocument(); expect(getByTestId('instance-ai-app-binding-app')).toHaveTextContent('Helpdesk'); const link = getByTestId('instance-ai-app-binding-agent'); expect(link).toHaveTextContent('Support'); @@ -151,7 +183,7 @@ describe('AppBindingApproval', () => { [['history'], 'Visitors can read back their conversation with this agent.'], ] as const)('shows the access line for agent permissions %j', (permissions, text) => { const { getByTestId } = renderComponent({ - props: { appBinding: { ...AGENT_BINDING, permissions: [...permissions] } }, + props: { appBindings: [{ ...AGENT_BINDING, permissions: [...permissions] }] }, }); expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent(text); @@ -159,7 +191,7 @@ describe('AppBindingApproval', () => { it('warns when the agent is not published', () => { const { getByTestId } = renderComponent({ - props: { appBinding: { ...AGENT_BINDING, published: false } }, + props: { appBindings: [{ ...AGENT_BINDING, published: false }] }, }); expect(getByTestId('instance-ai-app-binding-not-published')).toHaveTextContent( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts index d53ebe49d4e..17e3fa02749 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts @@ -571,15 +571,17 @@ describe('InstanceAiConfirmationPanel telemetry', () => { requestId: 'req-bind', severity: 'warning', message: 'Connect workflow "Echo" (wf-1) to app "Runner" as "submit"', - appBinding: { - kind: 'workflow', - appId: 'app-1', - appName: 'Runner', - appNamespace: 'runner', - workflowId: 'wf-1', - workflowName: 'Echo', - key: 'submit', - }, + appBindings: [ + { + kind: 'workflow', + appId: 'app-1', + appName: 'Runner', + appNamespace: 'runner', + workflowId: 'wf-1', + workflowName: 'Echo', + key: 'submit', + }, + ], }, { action: 'bind', appId: 'app-1' }, 'apps', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts index 828b9725aa2..4439e8a4bf6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/canvasPreview.utils.test.ts @@ -13,6 +13,7 @@ import { isAgentEditingWorkflow, isAgentEditingAgent, isAgentBuildingApp, + isAppPreviewShownIn, getLatestAppSourceEditId, } from '../canvasPreview.utils'; @@ -1433,6 +1434,29 @@ describe('isAgentBuildingApp', () => { }); }); +describe('isAppPreviewShownIn', () => { + const showCall = (appId: string, isLoading = true) => + makeToolCall({ toolName: 'apps', args: { action: 'show-preview', appId }, isLoading }); + + test('is true for an in-flight or completed show-preview call for the app, on any node', () => { + expect(isAppPreviewShownIn(makeAgentNode({ toolCalls: [showCall('app-1')] }), 'app-1')).toBe( + true, + ); + const child = makeAgentNode({ agentId: 'agent-2', toolCalls: [showCall('app-1', false)] }); + expect(isAppPreviewShownIn(makeAgentNode({ children: [child] }), 'app-1')).toBe(true); + }); + + test('is false for another app or another apps action', () => { + const node = makeAgentNode({ + toolCalls: [ + showCall('app-2'), + makeToolCall({ toolName: 'apps', args: { action: 'bindings', appId: 'app-1' } }), + ], + }); + expect(isAppPreviewShownIn(node, 'app-1')).toBe(false); + }); +}); + describe('getLatestAppSourceEditId', () => { const target = { appId: 'app-1', namespace: 'greeter' }; const write = ( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts index dc5981a205d..379ecb12e82 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/canvasPreview.utils.ts @@ -506,6 +506,17 @@ export function isAppCreatedIn(node: InstanceAiAgentNode, appId: string): boolea return node.children.some((child) => isAppCreatedIn(child, appId)); } +/** Whether an `apps show-preview` call for `appId` is in this agent tree. The call itself is the signal, so in-flight counts. */ +export function isAppPreviewShownIn(node: InstanceAiAgentNode, appId: string): boolean { + for (const tc of node.toolCalls) { + const args = tc.args as { action?: string; appId?: string } | undefined; + if (tc.toolName === 'apps' && args?.action === 'show-preview' && args.appId === appId) { + return true; + } + } + return node.children.some((child) => isAppPreviewShownIn(child, appId)); +} + function getAgentTarget(node: InstanceAiAgentNode): AgentArtifactTarget | undefined { if (node.targetResource?.type !== 'agent' || typeof node.targetResource.id !== 'string') { return undefined; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue index 37ddd6b7e81..584bc002377 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue @@ -5,13 +5,16 @@ import { useI18n } from '@n8n/i18n'; import { computed } from 'vue'; import ApprovalOptionList, { type ApprovalOption } from './ApprovalOptionList.vue'; -const props = defineProps<{ appBinding: AppBindingMeta; options: ApprovalOption[] }>(); +const props = defineProps<{ appBindings: AppBindingMeta[]; options: ApprovalOption[] }>(); const emit = defineEmits<{ select: [key: string] }>(); const i18n = useI18n(); +// One call binds one kind, so the first binding names the prompt for all of them. +const kind = computed(() => props.appBindings[0]?.kind); + const promptKey = computed(() => { - switch (props.appBinding.kind) { + switch (kind.value) { case 'dataTable': return 'instanceAi.appBinding.dataTable.prompt'; case 'agent': @@ -21,8 +24,7 @@ const promptKey = computed(() => { } }); -const accessKey = computed(() => { - const binding = props.appBinding; +function accessKey(binding: AppBindingMeta) { if (binding.kind === 'dataTable') { return binding.permissions.includes('read') ? binding.permissions.includes('write') @@ -38,7 +40,7 @@ const accessKey = computed(() => { : 'instanceAi.appBinding.agent.access.history'; } return null; -}); +}