mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 12:31:57 +02:00
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
This commit is contained in:
parent
32b4929ddb
commit
b2a450ca2f
|
|
@ -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'],
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<AgentSseEvent> {
|
||||
/** 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<string>;
|
||||
}
|
||||
|
||||
|
|
@ -397,6 +401,14 @@ function agentChat(start: () => Promise<Response>): 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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 `<Markdown :source>`, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { renderMarkdown } from './markdown';
|
||||
|
||||
const props = defineProps<{
|
||||
source: string;
|
||||
/** Render one line without a wrapping paragraph, e.g. inside a table cell. */
|
||||
inline?: boolean;
|
||||
class?: HTMLAttributes['class'];
|
||||
}>();
|
||||
|
||||
const html = computed(() => renderMarkdown(props.source, props.inline));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="inline ? 'span' : 'div'"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm leading-relaxed break-words',
|
||||
'[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
|
||||
'[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0',
|
||||
'[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5',
|
||||
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
|
||||
'[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||
'[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_hr]:my-4 [&_hr]:border-border',
|
||||
'[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
|
||||
'[&_img]:max-w-full [&_img]:rounded-md',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-html="html"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Markdown } from './Markdown.vue';
|
||||
export { renderMarkdown } from './markdown';
|
||||
|
|
@ -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 `<p>` for a single line. */
|
||||
export function renderMarkdown(source: string, inline = false): string {
|
||||
return inline ? md.renderInline(source) : md.render(source);
|
||||
}
|
||||
|
|
@ -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
|
||||
// `<Markdown :source="reply" />` 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<AgentSseEvent>`
|
||||
with `text(): Promise<string>`. 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
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ then import each as `import { X } from '@/components/ui/<name>'`:
|
|||
| 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` | `<Markdown :source="text" />` 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
|
||||
|
|
|
|||
|
|
@ -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/<namespace>/` 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/<namespace>/`. 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('<key>', 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.<key>.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.<key>` (`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.<key>` (`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: ["<name>", …])` 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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -239,7 +239,10 @@ function parseSchemaArray<T>(value: unknown, schema: z.ZodType<T>): T[] | undefi
|
|||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseSchemaRecord<T>(value: unknown, schema: z.ZodType<T>): T | undefined {
|
||||
function parseSchemaRecord<T>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<T, z.ZodTypeDef, unknown>,
|
||||
): 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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<typeof unbindSchema>;
|
||||
type BindingsInput = z.infer<typeof bindingsSchema>;
|
||||
type ShowPreviewInput = z.infer<typeof showPreviewSchema>;
|
||||
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<BindDescription>,
|
||||
): Promise<AppActionDenied | null> {
|
||||
const mode = context.permissions?.[permission];
|
||||
if (mode === 'blocked') {
|
||||
|
|
@ -1093,7 +1101,22 @@ async function requireBindApproval(
|
|||
}
|
||||
|
||||
type BoundApp = Omit<AppSummary, 'createdAt'>;
|
||||
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<T extends DescribedBinding>(
|
||||
bindings: AppBinding[],
|
||||
described: DescribedBinding[],
|
||||
is: (binding: DescribedBinding) => binding is T,
|
||||
toMeta: (binding: T) => AppBindingMeta,
|
||||
): Pick<BindDescription, 'appBindings'> {
|
||||
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/<namespace>/; 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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -47,71 +49,78 @@ const accessKey = computed(() => {
|
|||
<N8nText tag="div" size="medium" bold>
|
||||
{{ i18n.baseText(promptKey) }}
|
||||
</N8nText>
|
||||
<div :class="$style.row">
|
||||
<N8nIcon icon="app-window" size="large" />
|
||||
<N8nText size="small" bold data-test-id="instance-ai-app-binding-app">
|
||||
{{ appBinding.appName }}
|
||||
</N8nText>
|
||||
<N8nIcon icon="arrow-right" size="small" color="text-light" />
|
||||
<template v-if="appBinding.kind === 'dataTable'">
|
||||
<N8nIcon icon="table" size="large" />
|
||||
<N8nLink
|
||||
:to="`/projects/${appBinding.projectId}/datatables/${appBinding.dataTableId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-data-table"
|
||||
>
|
||||
{{ appBinding.dataTableName }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
<template v-else-if="appBinding.kind === 'agent'">
|
||||
<N8nIcon icon="robot" size="large" />
|
||||
<N8nLink
|
||||
:to="`/projects/${appBinding.projectId}/agents/${appBinding.agentId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-agent"
|
||||
>
|
||||
{{ appBinding.agentName }}
|
||||
</N8nLink>
|
||||
<N8nText
|
||||
v-if="!appBinding.published"
|
||||
size="small"
|
||||
color="warning"
|
||||
data-test-id="instance-ai-app-binding-not-published"
|
||||
>
|
||||
{{ i18n.baseText('apps.connections.agent.notPublished') }}
|
||||
<div
|
||||
v-for="binding in appBindings"
|
||||
:key="binding.key"
|
||||
:class="$style.binding"
|
||||
data-test-id="instance-ai-app-binding"
|
||||
>
|
||||
<div :class="$style.row">
|
||||
<N8nIcon icon="app-window" size="large" />
|
||||
<N8nText size="small" bold data-test-id="instance-ai-app-binding-app">
|
||||
{{ binding.appName }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nIcon icon="workflow" size="large" />
|
||||
<N8nLink
|
||||
:to="`/workflow/${appBinding.workflowId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-workflow"
|
||||
>
|
||||
{{ appBinding.workflowName }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
<N8nIcon icon="arrow-right" size="small" color="text-light" />
|
||||
<template v-if="binding.kind === 'dataTable'">
|
||||
<N8nIcon icon="table" size="large" />
|
||||
<N8nLink
|
||||
:to="`/projects/${binding.projectId}/datatables/${binding.dataTableId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-data-table"
|
||||
>
|
||||
{{ binding.dataTableName }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
<template v-else-if="binding.kind === 'agent'">
|
||||
<N8nIcon icon="robot" size="large" />
|
||||
<N8nLink
|
||||
:to="`/projects/${binding.projectId}/agents/${binding.agentId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-agent"
|
||||
>
|
||||
{{ binding.agentName }}
|
||||
</N8nLink>
|
||||
<N8nText
|
||||
v-if="!binding.published"
|
||||
size="small"
|
||||
color="warning"
|
||||
data-test-id="instance-ai-app-binding-not-published"
|
||||
>
|
||||
{{ i18n.baseText('apps.connections.agent.notPublished') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nIcon icon="workflow" size="large" />
|
||||
<N8nLink
|
||||
:to="`/workflow/${binding.workflowId}`"
|
||||
new-window
|
||||
theme="text"
|
||||
size="small"
|
||||
bold
|
||||
data-test-id="instance-ai-app-binding-workflow"
|
||||
>
|
||||
{{ binding.workflowName }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="accessKey(binding)"
|
||||
tag="div"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-test-id="instance-ai-app-binding-access"
|
||||
>
|
||||
{{ i18n.baseText(accessKey(binding)!) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="accessKey"
|
||||
tag="div"
|
||||
size="small"
|
||||
color="text-light"
|
||||
data-test-id="instance-ai-app-binding-access"
|
||||
>
|
||||
{{ i18n.baseText(accessKey) }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="appBinding.kind === 'agent'"
|
||||
v-if="kind === 'agent'"
|
||||
tag="div"
|
||||
size="small"
|
||||
color="text-light"
|
||||
|
|
@ -133,6 +142,12 @@ const accessKey = computed(() => {
|
|||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.binding {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
|||
import AppDetailsView from '@/features/apps/AppDetailsView.vue';
|
||||
import { useAppLivePreview } from '@/features/apps/composables/useAppLivePreview';
|
||||
import type { App } from '@/features/apps/apps.types';
|
||||
import { getLatestAppSourceEditId, isAppCreatedIn } from '../canvasPreview.utils';
|
||||
import {
|
||||
getLatestAppSourceEditId,
|
||||
isAppCreatedIn,
|
||||
isAppPreviewShownIn,
|
||||
} from '../canvasPreview.utils';
|
||||
import { getAppBuilderTargetFromThreadMetadata } from '../instanceAi.threadRuntime';
|
||||
import { useThread, useInstanceAiStore } from '../instanceAi.store';
|
||||
import { INSTANCE_AI_APP_BUILDER_TARGET_METADATA_KEY } from '../constants';
|
||||
|
|
@ -61,17 +65,22 @@ const live = useAppLivePreview(
|
|||
|
||||
// The run that creates the app starts from the scaffold, and a theme write
|
||||
// stores a source before the home page is touched; the dev server would show
|
||||
// the starter template until then. Hold the frame until that run ends. A
|
||||
// thread reopened on an existing app has no create in its active run.
|
||||
// the starter template until then. Hold the frame until the agent says the
|
||||
// template is gone (`apps show-preview`), or until that run ends. A thread
|
||||
// reopened on an existing app has no create in its active run.
|
||||
const creatingRun = computed(() => {
|
||||
const runId = thread.activeRunId;
|
||||
if (runId === null) return false;
|
||||
return thread.messages.some(
|
||||
(m) =>
|
||||
m.role === 'assistant' &&
|
||||
(m.runId === runId || (m.runIds?.includes(runId) ?? false)) &&
|
||||
m.agentTree !== undefined &&
|
||||
isAppCreatedIn(m.agentTree, props.appId),
|
||||
const trees = thread.messages.flatMap((m) =>
|
||||
m.role === 'assistant' &&
|
||||
(m.runId === runId || (m.runIds?.includes(runId) ?? false)) &&
|
||||
m.agentTree !== undefined
|
||||
? [m.agentTree]
|
||||
: [],
|
||||
);
|
||||
return (
|
||||
trees.some((tree) => isAppCreatedIn(tree, props.appId)) &&
|
||||
!trees.some((tree) => isAppPreviewShownIn(tree, props.appId))
|
||||
);
|
||||
});
|
||||
const liveUrl = computed(() => (creatingRun.value ? undefined : live.liveUrl.value));
|
||||
|
|
|
|||
|
|
@ -654,8 +654,8 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
|||
|
||||
<!-- App binding -->
|
||||
<AppBindingApproval
|
||||
v-else-if="chunk.item.toolCall.confirmation.appBinding"
|
||||
:app-binding="chunk.item.toolCall.confirmation.appBinding!"
|
||||
v-else-if="chunk.item.toolCall.confirmation.appBindings"
|
||||
:app-bindings="chunk.item.toolCall.confirmation.appBindings!"
|
||||
:options="buildApprovalOptions(chunk.item)"
|
||||
@select="(key) => handleApprovalSelect(chunk.item, key)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,30 @@ const appEdit = (toolCallId: string, path: string): InstanceAiAgentNode => ({
|
|||
timeline: [],
|
||||
});
|
||||
|
||||
const appsCall = (
|
||||
toolCallId: string,
|
||||
args: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
): InstanceAiAgentNode => ({
|
||||
agentId: 'agent-1',
|
||||
role: 'orchestrator',
|
||||
status: 'active',
|
||||
textContent: '',
|
||||
reasoning: '',
|
||||
toolCalls: [{ toolCallId, toolName: 'apps', args, isLoading: result === undefined, result }],
|
||||
children: [],
|
||||
timeline: [],
|
||||
});
|
||||
|
||||
const threadState = reactive({
|
||||
id: 'thread-1',
|
||||
isStreaming: false,
|
||||
activeRunId: null as string | null,
|
||||
producedArtifacts: new Map<
|
||||
string,
|
||||
{ type: string; id: string; name: string; projectId: string; namespace?: string }
|
||||
>([['app-1', { type: 'app', id: 'app-1', name: 'Greeter', projectId: 'proj-1' }]]),
|
||||
messages: [] as Array<{ agentTree?: InstanceAiAgentNode }>,
|
||||
messages: [] as Array<{ agentTree?: InstanceAiAgentNode; role?: string; runId?: string }>,
|
||||
});
|
||||
const metadataState = ref<Record<string, unknown>>();
|
||||
const updateThreadMetadataMock = vi.fn(
|
||||
|
|
@ -127,6 +143,7 @@ describe('InstanceAiAppPreview', () => {
|
|||
settledCount.value = 0;
|
||||
previewAhead.value = false;
|
||||
threadState.messages = [];
|
||||
threadState.activeRunId = null;
|
||||
threadState.producedArtifacts.get('app-1')!.namespace = undefined;
|
||||
markPreviewPublished.mockClear();
|
||||
updateThreadMetadataMock.mockClear();
|
||||
|
|
@ -272,6 +289,58 @@ describe('InstanceAiAppPreview', () => {
|
|||
expect(latestSourceEditId.value).toBe('tc-1');
|
||||
});
|
||||
|
||||
describe('during the run that creates the app', () => {
|
||||
const ready = {
|
||||
status: 'ready',
|
||||
url: '/apps-preview/tok/',
|
||||
expiresAt: '2026-09-09T00:00:00Z',
|
||||
} as const;
|
||||
const created = {
|
||||
agentTree: appsCall('tc-create', { action: 'create' }, { app: { id: 'app-1' } }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
liveStatus.value = ready;
|
||||
threadState.activeRunId = 'run-1';
|
||||
});
|
||||
|
||||
it('holds the frame as starting until the run ends', async () => {
|
||||
threadState.messages = [{ role: 'assistant', runId: 'run-1', ...created }];
|
||||
const wrapper = mountPreview();
|
||||
const view = wrapper.get('[data-test-id="app-details-view-stub"]');
|
||||
|
||||
expect(view.attributes('data-live-status')).toBe('starting');
|
||||
expect(view.attributes('data-live-url')).toBeUndefined();
|
||||
|
||||
threadState.activeRunId = null;
|
||||
await flushPromises();
|
||||
expect(view.attributes('data-live-url')).toBe('/apps-preview/tok/');
|
||||
});
|
||||
|
||||
it('releases the frame once the agent calls show-preview for this app', async () => {
|
||||
threadState.messages = [{ role: 'assistant', runId: 'run-1', ...created }];
|
||||
const wrapper = mountPreview();
|
||||
const view = wrapper.get('[data-test-id="app-details-view-stub"]');
|
||||
|
||||
threadState.messages.push({
|
||||
role: 'assistant',
|
||||
runId: 'run-1',
|
||||
agentTree: appsCall('tc-show', { action: 'show-preview', appId: 'app-2' }),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(view.attributes('data-live-url')).toBeUndefined();
|
||||
|
||||
threadState.messages.push({
|
||||
role: 'assistant',
|
||||
runId: 'run-1',
|
||||
agentTree: appsCall('tc-show-2', { action: 'show-preview', appId: 'app-1' }),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(view.attributes('data-live-status')).toBe('ready');
|
||||
expect(view.attributes('data-live-url')).toBe('/apps-preview/tok/');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the building indicator while an apps build call is in flight', () => {
|
||||
const wrapper = mountPreview({ versionId: 'v-1', building: true });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user