diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 15973b74212..079e584aac4 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -316,6 +316,7 @@ export { confirmationInputTypeSchema, instanceAiTargetApprovalSchema, channelConfigSchema, + formAppearanceConfirmationSchema, credentialPlaceholderDefSchema, credentialRequestSchema, credentialSetupHintSchema, @@ -410,6 +411,7 @@ export type { InstanceAiConfirmation, InstanceAiConfirmationInputType, InstanceAiChannelConfig, + InstanceAiFormAppearance, InstanceAiConfirmationRequestPayload, InstanceAiConfirmationSeverity, InstanceAiCredentialPlaceholderDef, 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 2012bc0fb28..c404d1b6334 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -555,6 +555,18 @@ export const channelConfigSchema = z.object({ }); export type InstanceAiChannelConfig = z.infer; +/** Rendered form-appearance confirmation card (Instance AI `forms` apply-theme). */ +export const formAppearanceConfirmationSchema = z.object({ + workflowId: z.string(), + nodeName: z.string().optional(), + scope: z.enum(['node', 'workflow']), + /** Server-rendered form HTML shown read-only in a sandboxed iframe. */ + previewHtml: z.string(), + preset: z.string().optional(), + themeLabel: z.string().optional(), +}); +export type InstanceAiFormAppearance = z.infer; + export const confirmationInputTypeSchema = z.enum([ 'approval', 'text', @@ -649,6 +661,11 @@ export const confirmationRequestPayloadSchema = z.object({ .describe( 'When present, renders agent chat-channel setup UI for this integration type and agent', ), + formAppearance: formAppearanceConfirmationSchema + .optional() + .describe( + 'When present, renders the form appearance confirmation card (rendered form preview + approve/deny)', + ), }); export type InstanceAiConfirmationRequestPayload = z.infer; @@ -682,6 +699,7 @@ export function isDisplayableConfirmationRequest( if (hasItems(payload.credentialRequests)) return true; if (payload.domainAccess) return true; if (payload.channelConfig) return true; + if (payload.formAppearance) return true; const inputType = payload.inputType ?? 'approval'; switch (inputType) { @@ -1189,6 +1207,7 @@ export interface InstanceAiConfirmation { tasks?: TaskList; resourceDecision?: GatewayConfirmationRequiredPayload; channelConfig?: InstanceAiChannelConfig; + formAppearance?: InstanceAiFormAppearance; expired?: boolean; } diff --git a/packages/@n8n/instance-ai/skills/forms/SKILL.md b/packages/@n8n/instance-ai/skills/forms/SKILL.md new file mode 100644 index 00000000000..2648f75f62c --- /dev/null +++ b/packages/@n8n/instance-ai/skills/forms/SKILL.md @@ -0,0 +1,62 @@ +--- +name: forms +description: >- + Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes) + through the forms tool. Load when the user asks how a form looks, or to + rebrand, recolor, restyle, theme, or "make prettier / match X" a form, or + when they just built or are editing a form workflow and appearance is + relevant. Appearance only (colors, fonts, spacing, themes) — not field or + logic editing. +recommended_tools: + - forms +--- + +# Forms — Appearance & Theming + +Use the `forms` tool to preview and restyle the appearance of form-trigger +workflows. This is **appearance only** (colors, fonts, spacing, themes). Field +and logic editing is not supported here yet — for that, point the user to the +form node in the editor. + +## When to use + +- The user asks how a form **looks**, or wants to preview it. +- The user wants to rebrand / recolor / restyle / theme a form, or "make it + match our brand", "make it dark", "make it prettier". +- The user just built or is editing a form workflow — proactively offer to + preview it and to apply a theme. Keep the offer short and only when relevant. + +## Actions + +- `describe` — read a form node's current theme/appearance (resolved overrides, + matched preset, attribution) and list the workflow's form nodes. Do this + before changing a theme. +- `list-appearance-options` — list built-in themes and the editable CSS-variable + catalog. **Call this before composing a custom theme** so you only use valid + variable names and values. +- `preview` — render a single form (non-interactive) so the user can see it, + e.g. before/after a restyle. Optionally pass a `preset` or `overrides` to + preview a proposed look. +- `apply-theme` — apply a built-in `preset` or a custom `overrides` map to one + node (`scope: 'node'`) or every form node (`scope: 'workflow'`). Requires + user confirmation (shows a rendered preview card). + +## Apply a built-in theme + +1. `forms(describe)` to see the current look and the form nodes. +2. `forms(apply-theme, { preset: 'dark', scope: 'workflow' })` — the user + approves the preview card, then the theme is written. + +## Create a theme from a vague description + +For a mood/brand/style request ("calm, minimal, spa-like", "match our green +brand"): + +1. `forms(list-appearance-options)` — learn the presets and the CSS-variable + catalog (each variable has a type, group, default, and description). +2. Compose an `overrides` map from that catalog. You may start from the closest + preset and adjust. +3. Optionally `forms(preview, { overrides, nodeName })` to check the look. +4. `forms(apply-theme, { overrides, scope })`. If the tool returns validation + `errors` (unknown variable or wrong value type), fix those values and retry — + never write junk to the node. diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 107152ef286..5e1c247d1a7 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -95,6 +95,17 @@ ${licenseHints.map((hint) => `- ${hint}`).join('\n')} `; } +function getFormsSection(): string { + return ` +## Form Appearance + +When the user is working with a form-trigger workflow (Form Trigger / Form nodes), or asks how a form **looks** — branding, colors, fonts, spacing, theme, "make it match X", "make it prettier" — use the \`forms\` tool. + +- Be proactive: if the user just built or is editing a form workflow, briefly offer to preview it and to restyle or apply a theme. Keep the offer short and only when relevant. +- To restyle from a vague description, first \`forms(list-appearance-options)\` to learn the themes and CSS variables, compose overrides, optionally \`preview\`, then \`apply-theme\` (which asks for confirmation). +- Appearance only for now: if the user asks to change form **fields or logic**, say that isn't supported through this tool yet and point them to the form node in the editor.`; +} + function getReadOnlySection(branchReadOnly?: boolean): string { if (!branchReadOnly) return ''; return ` @@ -173,6 +184,7 @@ Don't fabricate provider setup mechanics (credential field names, secret values, ${UNTRUSTED_CONTENT_DOCTRINE} ${getComputerUsePrompt({ browserAvailable, localGateway })} +${getFormsSection()} ${getLicenseLimitationsSection(licenseHints)} ${getReadOnlySection(branchReadOnly)}`; } diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index ecb0bf5068f..b4ba859c1ea 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -601,6 +601,9 @@ export declare const PLANNED_TASK_PERMISSION_OVERRIDES: typeof PlannedTaskPermis export type { InstanceAiContext, InstanceAiWorkflowService, + InstanceAiFormService, + FormNodeSummary, + FormNodeAppearance, InstanceAiExecutionService, InstanceAiCredentialService, InstanceAiNodeService, diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index 55cdcd35d92..8ea0b99c579 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -7,6 +7,7 @@ import { gatewayConfirmationRequiredPayloadSchema, webSearchMetaSchema, channelConfigSchema, + formAppearanceConfirmationSchema, } from '@n8n/api-types'; import type { InstanceAiEvent } from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; @@ -344,6 +345,10 @@ function mapSuspendedChunk( gatewayConfirmationRequiredPayloadSchema, ); const channelConfig = parseSchemaRecord(suspendPayload.channelConfig, channelConfigSchema); + const formAppearance = parseSchemaRecord( + suspendPayload.formAppearance, + formAppearanceConfirmationSchema, + ); const targetApprovalResult = isRecord(suspendPayload.builderCheckpoint) ? APPROVAL_SUSPEND_SCHEMA.safeParse(suspendPayload) : undefined; @@ -385,6 +390,7 @@ function mapSuspendedChunk( ...(planItems ? { planItems } : {}), ...(resourceDecision ? { resourceDecision } : {}), ...(channelConfig ? { channelConfig } : {}), + ...(formAppearance ? { formAppearance } : {}), }, }; } diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/forms.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/forms.tool.test.ts new file mode 100644 index 00000000000..889d857ffab --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/__tests__/forms.tool.test.ts @@ -0,0 +1,189 @@ +import { executeTool } from '../../__tests__/tool-test-utils'; +import type { InstanceAiContext, InstanceAiFormService } from '../../types'; +import { createFormsTool } from '../forms.tool'; + +function createFormService(overrides: Partial = {}): InstanceAiFormService { + return { + getFormNode: vi.fn().mockResolvedValue({ + nodeName: 'My Form', + nodeType: 'n8n-nodes-base.formTrigger', + overrides: {}, + appendAttribution: true, + preset: 'light', + }), + listFormNodes: vi.fn().mockResolvedValue([ + { + nodeName: 'My Form', + nodeType: 'n8n-nodes-base.formTrigger', + isTrigger: true, + preset: 'light', + }, + ]), + getWorkflowName: vi.fn().mockResolvedValue('My Workflow'), + applyAppearance: vi.fn().mockResolvedValue({ updatedNodeNames: ['My Form'] }), + renderPreview: vi.fn().mockResolvedValue('preview'), + ...overrides, + }; +} + +function createMockContext(formService: InstanceAiFormService): InstanceAiContext { + return { + userId: 'user-1', + formService, + permissions: {}, + } as unknown as InstanceAiContext; +} + +describe('forms tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('describe', () => { + it("returns the node's resolved appearance and the workflow's form nodes", async () => { + const formService = createFormService(); + const tool = createFormsTool(createMockContext(formService)); + + const result = await executeTool<{ + node?: { nodeName: string; preset: string }; + formNodes: Array<{ nodeName: string }>; + }>(tool, { action: 'describe', workflowId: 'wf1', nodeName: 'My Form' }, {} as never); + + expect(formService.getFormNode).toHaveBeenCalledWith('wf1', 'My Form'); + expect(result.node?.nodeName).toBe('My Form'); + expect(result.node?.preset).toBe('light'); + expect(result.formNodes).toHaveLength(1); + }); + + it('reports not-found when the workflow has no form node', async () => { + const formService = createFormService({ + getFormNode: vi.fn().mockResolvedValue(null), + listFormNodes: vi.fn().mockResolvedValue([]), + }); + const tool = createFormsTool(createMockContext(formService)); + + const result = await executeTool<{ found: boolean }>( + tool, + { action: 'describe', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.found).toBe(false); + }); + }); + + describe('list-appearance-options', () => { + it('returns preset ids with descriptions and the CSS-variable catalog', async () => { + const formService = createFormService(); + const tool = createFormsTool(createMockContext(formService)); + + const result = await executeTool<{ + presets: Array<{ id: string; description: string }>; + variables: Array<{ variable: string; type: string }>; + }>(tool, { action: 'list-appearance-options' }, {} as never); + + expect(result.presets.some((p) => p.id === 'dark')).toBe(true); + expect(result.variables.some((v) => v.variable === '--color-background')).toBe(true); + expect(formService.getFormNode).not.toHaveBeenCalled(); + }); + }); + + describe('apply-theme', () => { + it('suspends with a preview then applies the preset on approval', async () => { + const formService = createFormService(); + const tool = createFormsTool(createMockContext(formService)); + const suspend = vi.fn(); + + // First call — no resume data: renders a preview and suspends. + await executeTool( + tool, + { + action: 'apply-theme', + workflowId: 'wf1', + scope: 'node', + nodeName: 'My Form', + preset: 'dark', + }, + { suspend, resumeData: undefined } as never, + ); + + expect(formService.renderPreview).toHaveBeenCalledWith('wf1', { + nodeName: 'My Form', + customCss: expect.stringContaining(':root'), + }); + expect(suspend).toHaveBeenCalledTimes(1); + expect(suspend.mock.calls[0][0]).toMatchObject({ + severity: 'info', + formAppearance: { + workflowId: 'wf1', + nodeName: 'My Form', + scope: 'node', + previewHtml: 'preview', + preset: 'dark', + }, + }); + expect(formService.applyAppearance).not.toHaveBeenCalled(); + + // Second call — approved resume data: writes the CSS. + const result = await executeTool<{ success: boolean; updatedNodeNames: string[] }>( + tool, + { + action: 'apply-theme', + workflowId: 'wf1', + scope: 'node', + nodeName: 'My Form', + preset: 'dark', + }, + { suspend, resumeData: { approved: true } } as never, + ); + + expect(formService.applyAppearance).toHaveBeenCalledWith('wf1', { + nodeNames: ['My Form'], + customCss: expect.stringContaining(':root'), + appendAttribution: true, + }); + expect(result.success).toBe(true); + expect(result.updatedNodeNames).toEqual(['My Form']); + }); + + it('does not apply when the user denies', async () => { + const formService = createFormService(); + const tool = createFormsTool(createMockContext(formService)); + + const result = await executeTool<{ success: boolean; denied: boolean }>( + tool, + { action: 'apply-theme', workflowId: 'wf1', scope: 'node', preset: 'dark' }, + { suspend: vi.fn(), resumeData: { approved: false } } as never, + ); + + expect(result.denied).toBe(true); + expect(formService.applyAppearance).not.toHaveBeenCalled(); + }); + + it('returns validation errors for invalid overrides without suspending', async () => { + const formService = createFormService(); + const tool = createFormsTool(createMockContext(formService)); + const suspend = vi.fn(); + + const result = await executeTool<{ + success: boolean; + errors?: Array<{ variable: string; reason: string }>; + }>( + tool, + { + action: 'apply-theme', + workflowId: 'wf1', + scope: 'node', + overrides: { '--not-a-real-variable': 'nope' }, + }, + { suspend, resumeData: undefined } as never, + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0]?.variable).toBe('--not-a-real-variable'); + expect(suspend).not.toHaveBeenCalled(); + expect(formService.applyAppearance).not.toHaveBeenCalled(); + expect(formService.renderPreview).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/forms.tool.ts b/packages/@n8n/instance-ai/src/tools/forms.tool.ts new file mode 100644 index 00000000000..88c553208f6 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/forms.tool.ts @@ -0,0 +1,457 @@ +/** + * Forms tool — appearance/theming of form-trigger workflows. + * + * v1 is appearance-only: read a form node's current look (`describe`), learn the + * theming surface (`list-appearance-options`), render a non-interactive preview + * (`preview`), and apply a preset or custom overrides to node(s) behind an HITL + * confirmation card (`apply-theme`). The action union and `InstanceAiFormService` + * are shaped so field/structure editing can be added later without rework. + * + * Thin-tool rule: no business logic here — every read/write goes through + * `context.formService`. + */ +import { Tool } from '@n8n/agents'; +import { + formAppearanceConfirmationSchema, + instanceAiConfirmationSeveritySchema, +} from '@n8n/api-types'; +import { + applyFormThemePreset, + assembleFormCss, + FORM_CSS_VARIABLE_CONTROLS, + FORM_CSS_VARIABLE_GROUPS, + FORM_THEMES, + resolveFormTheme, + validateThemeOverrides, +} from 'n8n-workflow'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; +import type { FormNodeSummary, InstanceAiContext } from '../types'; + +// ── Preset descriptions (LLM-facing) ───────────────────────────────────────── +// One-line human descriptions per built-in preset id, so `list-appearance-options` +// teaches the model what each preset looks like. +const PRESET_DESCRIPTIONS: Record = { + light: 'Default light theme — white card on a near-white page', + dark: 'Dark background with light text', + dense: 'Reduced spacing and smaller headings, light background', + compact: 'Narrow, tightly-spaced layout', + compactDark: 'Compact layout on a dark background', + enterprise: 'Formal corporate blue with near-square corners', + enterpriseDark: 'Formal corporate look on a dark background', + fun: 'Playful pink/purple with heavily rounded corners', + funColorful: 'Bright, colorful and highly rounded', +}; + +// Human display names for preset ids (the id is a property name; the UI shows this). +const PRESET_LABELS: Record = { + light: 'Light', + dark: 'Dark', + dense: 'Dense', + compact: 'Compact', + compactDark: 'Compact Dark', + enterprise: 'Enterprise', + enterpriseDark: 'Enterprise Dark', + fun: 'Fun', + funColorful: 'Fun Colorful', +}; + +/** Display label for a preset id: proper name, "Custom theme", or the id as a fallback. */ +function presetLabel(id: string): string { + if (id === 'custom') return 'Custom theme'; + return PRESET_LABELS[id] ?? id; +} + +// Shared field descriptions must be identical across the discriminated union +// (the MCP-schema sanitizer merges same-named fields and rejects conflicts). +const WORKFLOW_ID_DESC = 'ID of the workflow containing the form node(s)'; +const NODE_NAME_DESC = + "Form node name. Omit to use the Form Trigger (or first form node). For apply-theme this is the target when scope='node' and is ignored when scope='workflow'."; +const PRESET_DESC = 'Built-in theme preset id (e.g. "dark"). Mutually exclusive with overrides.'; +const OVERRIDES_DESC = + 'Custom CSS-variable overrides map (e.g. {"--color-background":"#101010"}). Mutually exclusive with preset.'; + +// ── Action schemas ─────────────────────────────────────────────────────────── + +const describeAction = z.object({ + action: z + .literal('describe') + .describe( + "Read a form node's current theme/appearance (resolved CSS overrides, matched preset, attribution) before changing it. Also lists the workflow's form nodes.", + ), + workflowId: z.string().describe(WORKFLOW_ID_DESC), + nodeName: z.string().optional().describe(NODE_NAME_DESC), +}); + +const listAppearanceOptionsAction = z.object({ + action: z + .literal('list-appearance-options') + .describe( + 'List built-in themes and the editable CSS-variable catalog (variable, type, group, default, description). Call this before creating a custom theme so you compose overrides from valid variable names and values.', + ), +}); + +const previewAction = z.object({ + action: z + .literal('preview') + .describe( + 'Show the user what a form looks like, non-interactively (e.g. before/after a restyle). Omit nodeName to preview EVERY form step (the whole form); pass nodeName to preview a single step. Optionally pass a preset or overrides to preview a proposed look before applying it. Does not modify the workflow.', + ), + workflowId: z.string().describe(WORKFLOW_ID_DESC), + nodeName: z.string().optional().describe(NODE_NAME_DESC), + preset: z.string().optional().describe(PRESET_DESC), + overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC), +}); + +const applyThemeAction = z.object({ + action: z + .literal('apply-theme') + .describe( + 'Apply a built-in theme or a custom set of CSS overrides to form node(s). Requires user confirmation: suspends an approval card showing the rendered preview, then writes the CSS on approval. Provide EXACTLY ONE of `preset` or `overrides`. To create a theme from a vague description (mood/brand/style), FIRST call list-appearance-options, compose `overrides` from that catalog, optionally `preview`, then apply. If the overrides are rejected, fix the reported values and retry.', + ), + workflowId: z.string().describe(WORKFLOW_ID_DESC), + scope: z + .enum(['node', 'workflow']) + .describe("'node' targets a single form node; 'workflow' targets every form node."), + nodeName: z.string().optional().describe(NODE_NAME_DESC), + preset: z.string().optional().describe(PRESET_DESC), + overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC), +}); + +const actionUnion = z.discriminatedUnion('action', [ + describeAction, + listAppearanceOptionsAction, + previewAction, + applyThemeAction, +]); + +const inputSchema = sanitizeInputSchema(actionUnion); + +type Input = z.infer; + +// ── Suspend / resume schemas ───────────────────────────────────────────────── + +const suspendSchema = z.object({ + requestId: z.string(), + message: z.string(), + severity: instanceAiConfirmationSeveritySchema, + formAppearance: formAppearanceConfirmationSchema, +}); + +const resumeSchema = z.object({ + approved: z.boolean(), +}); + +interface FormsToolContext { + resumeData: z.infer | undefined; + suspend: (payload: z.infer) => Promise; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Resolve the CSS-variable overrides an apply/preview request describes. */ +function resolveOverrides(input: { + preset?: string; + overrides?: Record; +}): + | { ok: true; overrides: Record; preset?: string; themeLabel: string } + | { + ok: false; + error: string; + errors?: Array<{ variable: string; value: string; reason: string }>; + } { + const hasPreset = typeof input.preset === 'string' && input.preset.length > 0; + const hasOverrides = input.overrides !== undefined && Object.keys(input.overrides).length > 0; + + if (hasPreset && hasOverrides) { + return { ok: false, error: 'Provide exactly one of `preset` or `overrides`, not both.' }; + } + + if (hasPreset) { + const overrides = applyFormThemePreset(input.preset!); + if (!overrides) { + return { + ok: false, + error: `Unknown preset "${input.preset!}". Call list-appearance-options for valid preset ids.`, + }; + } + return { ok: true, overrides, preset: input.preset, themeLabel: presetLabel(input.preset!) }; + } + + if (hasOverrides) { + const result = validateThemeOverrides(input.overrides!); + if (!result.valid) { + return { + ok: false, + error: + 'Some overrides are invalid. Fix the values below and retry — do not write them to the node.', + errors: result.errors, + }; + } + const matched = resolveFormTheme(result.overrides); + return { + ok: true, + overrides: result.overrides, + themeLabel: presetLabel(matched), + }; + } + + return { ok: false, error: 'Provide either `preset` or `overrides`.' }; +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +async function handleDescribe( + context: InstanceAiContext, + input: Extract, +) { + const [node, formNodes, workflowName] = await Promise.all([ + context.formService.getFormNode(input.workflowId, input.nodeName), + context.formService.listFormNodes(input.workflowId), + context.formService.getWorkflowName(input.workflowId), + ]); + + if (!node) { + return { + workflowId: input.workflowId, + workflowName, + found: false as const, + formNodes, + hint: + formNodes.length > 0 + ? 'That node was not found. Pick a form node from `formNodes`.' + : 'This workflow has no Form Trigger or Form nodes.', + }; + } + + return { + workflowId: input.workflowId, + workflowName, + node: { + nodeName: node.nodeName, + nodeType: node.nodeType, + preset: node.preset, + overrides: node.overrides, + appendAttribution: node.appendAttribution, + }, + formNodes, + }; +} + +function handleListAppearanceOptions() { + return { + presets: FORM_THEMES.map((theme) => ({ + id: theme.id, + description: PRESET_DESCRIPTIONS[theme.id] ?? '', + })), + groups: FORM_CSS_VARIABLE_GROUPS, + variables: FORM_CSS_VARIABLE_CONTROLS.map((control) => ({ + variable: control.variable, + type: control.type, + group: control.group, + default: control.default, + description: control.description, + })), + }; +} + +async function handlePreview( + context: InstanceAiContext, + input: Extract, +) { + const [formNodes, workflowName] = await Promise.all([ + context.formService.listFormNodes(input.workflowId), + context.formService.getWorkflowName(input.workflowId), + ]); + if (formNodes.length === 0) { + return { + workflowId: input.workflowId, + workflowName, + found: false as const, + formNodes, + hint: 'This workflow has no Form Trigger or Form nodes.', + }; + } + + // A proposed preset/overrides is optional — without one we render the saved + // look. When present it applies to every previewed step. + let customCss: string | undefined; + let themeLabel: string | undefined; + let preset: string | undefined; + if (input.preset !== undefined || input.overrides !== undefined) { + const resolved = resolveOverrides(input); + if (!resolved.ok) { + return { workflowId: input.workflowId, ...resolved }; + } + customCss = assembleFormCss(resolved.overrides); + themeLabel = resolved.themeLabel; + preset = resolved.preset; + } + + // A named node previews just that step; otherwise preview EVERY form step. + let targets: FormNodeSummary[]; + if (input.nodeName) { + const target = formNodes.find((n) => n.nodeName === input.nodeName); + if (!target) { + return { + workflowId: input.workflowId, + workflowName, + found: false as const, + formNodes, + hint: 'That node was not found. Pick a form node from `formNodes`.', + }; + } + targets = [target]; + } else { + targets = formNodes; + } + + const previews = await Promise.all( + targets.map(async (n) => ({ + nodeName: n.nodeName, + // A proposed theme labels every step the same; otherwise show each + // step's own current theme (display name). + themeLabel: themeLabel ?? presetLabel(n.preset), + previewHtml: await context.formService.renderPreview(input.workflowId, { + nodeName: n.nodeName, + ...(customCss !== undefined ? { customCss } : {}), + }), + })), + ); + + return { workflowId: input.workflowId, workflowName, ...(preset ? { preset } : {}), previews }; +} + +async function handleApplyTheme( + context: InstanceAiContext, + input: Extract, + ctx: FormsToolContext, +) { + if (context.permissions?.updateWorkflow === 'blocked') { + return { success: false, denied: true, reason: 'Action blocked by admin' }; + } + + const resolved = resolveOverrides(input); + if (!resolved.ok) { + // Validation failures return to the model so it can self-correct — no suspend. + return { success: false, workflowId: input.workflowId, ...resolved }; + } + + // Resolve the target node(s). + const [formNodes, workflowName] = await Promise.all([ + context.formService.listFormNodes(input.workflowId), + context.formService.getWorkflowName(input.workflowId), + ]); + if (formNodes.length === 0) { + return { + success: false, + workflowId: input.workflowId, + error: 'This workflow has no Form Trigger or Form nodes.', + }; + } + + let targetNodeNames: string[]; + if (input.scope === 'workflow') { + targetNodeNames = formNodes.map((n) => n.nodeName); + } else { + const target = input.nodeName + ? formNodes.find((n) => n.nodeName === input.nodeName) + : (formNodes.find((n) => n.isTrigger) ?? formNodes[0]); + if (!target) { + return { + success: false, + workflowId: input.workflowId, + error: `Form node "${input.nodeName ?? ''}" not found.`, + formNodes, + }; + } + targetNodeNames = [target.nodeName]; + } + + const customCss = assembleFormCss(resolved.overrides); + const previewNodeName = targetNodeNames[0]; + + // First call — render preview and suspend the confirmation card. + const { resumeData } = ctx; + if (resumeData === undefined || resumeData === null) { + const previewHtml = await context.formService.renderPreview(input.workflowId, { + nodeName: previewNodeName, + customCss, + }); + const scopeLabel = + input.scope === 'workflow' + ? `all ${targetNodeNames.length} form node(s)` + : `"${previewNodeName}"`; + return await ctx.suspend({ + requestId: nanoid(), + message: `Apply ${resolved.themeLabel} to ${scopeLabel}?`, + severity: 'info', + formAppearance: { + workflowId: input.workflowId, + ...(input.scope === 'node' ? { nodeName: previewNodeName } : {}), + scope: input.scope, + previewHtml, + ...(resolved.preset ? { preset: resolved.preset } : {}), + themeLabel: resolved.themeLabel, + }, + }); + } + + // Denied. + if (!resumeData.approved) { + return { success: false, denied: true, reason: 'User denied the action' }; + } + + // Approved — write to the target node(s). + try { + const { updatedNodeNames } = await context.formService.applyAppearance(input.workflowId, { + nodeNames: targetNodeNames, + customCss, + appendAttribution: true, + }); + return { + success: true, + workflowId: input.workflowId, + workflowName, + scope: input.scope, + themeLabel: resolved.themeLabel, + updatedNodeNames, + }; + } catch (error) { + return { + success: false, + workflowId: input.workflowId, + error: error instanceof Error ? error.message : 'Failed to apply appearance', + }; + } +} + +// ── Tool factory ───────────────────────────────────────────────────────────── + +export function createFormsTool(context: InstanceAiContext) { + return new Tool('forms') + .description( + 'Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes). Appearance only — colors, fonts, spacing and themes — not field or logic editing (yet). Actions: describe (read current theme), list-appearance-options (themes + CSS variable catalog), preview (show a form, non-interactive), apply-theme (apply a preset or custom overrides, with approval).', + ) + .input(inputSchema) + .suspend(suspendSchema) + .resume(resumeSchema) + .handler(async (input, ctx) => { + const formInput = input as Input; + switch (formInput.action) { + case 'describe': + return await handleDescribe(context, formInput); + case 'list-appearance-options': + return handleListAppearanceOptions(); + case 'preview': + return await handlePreview(context, formInput); + case 'apply-theme': + return await handleApplyTheme(context, formInput, ctx); + default: + return { error: `Unknown action: ${(formInput as { action: string }).action}` }; + } + }) + .build(); +} diff --git a/packages/@n8n/instance-ai/src/tools/index.ts b/packages/@n8n/instance-ai/src/tools/index.ts index 4e347d73341..751e09cf96b 100644 --- a/packages/@n8n/instance-ai/src/tools/index.ts +++ b/packages/@n8n/instance-ai/src/tools/index.ts @@ -29,6 +29,7 @@ const loadEvalConfigTool = lazyMod( const loadExecutionsTool = lazyMod( () => require('./executions.tool') as typeof import('./executions.tool'), ); +const loadFormsTool = lazyMod(() => require('./forms.tool') as typeof import('./forms.tool')); const loadNodesTool = lazyMod(() => require('./nodes.tool') as typeof import('./nodes.tool')); const loadMcpServersTool = lazyMod( () => require('./mcp-servers.tool') as typeof import('./mcp-servers.tool'), @@ -109,6 +110,7 @@ export function createAllTools(context: InstanceAiContext): InstanceAiToolRegist [DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)], [DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()], [DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)], + [DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)], ]; // eval-config is flag-gated: the adapter only wires evaluationConfigService @@ -142,6 +144,7 @@ export function createOrchestratorDomainTools(context: InstanceAiContext): Insta [DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)], [DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()], [DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)], + [DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)], ]; // eval-config is flag-gated: the adapter only wires evaluationConfigService diff --git a/packages/@n8n/instance-ai/src/tools/tool-ids.ts b/packages/@n8n/instance-ai/src/tools/tool-ids.ts index 51b009427d0..c081de0d2ec 100644 --- a/packages/@n8n/instance-ai/src/tools/tool-ids.ts +++ b/packages/@n8n/instance-ai/src/tools/tool-ids.ts @@ -16,6 +16,7 @@ export const DOMAIN_TOOL_IDS = { PARSE_FILE: 'parse-file', AGENTS: 'agents', MCP_SERVERS: 'mcp-servers', + FORMS: 'forms', } as const; /** Trace-only chain-typed child run emitted by `build-workflow` with the @@ -62,6 +63,8 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set([ DOMAIN_TOOL_IDS.PARSE_FILE, DOMAIN_TOOL_IDS.BUILD_WORKFLOW, DOMAIN_TOOL_IDS.NODES, + // Core appearance/theming tool for form-trigger workflows — always loaded (no gating). + DOMAIN_TOOL_IDS.FORMS, ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW, DOMAIN_TOOL_IDS.RESEARCH, // Paired with RESEARCH on purpose: the research tool tells the agent to prefer diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 46bf377c4db..b39b46ecaed 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -365,6 +365,52 @@ export interface InstanceAiWorkflowService { getLatestRunData?(workflowId: string): Promise | null>; } +/** A form-capable node in a workflow (Form Trigger or Form node). */ +export interface FormNodeSummary { + nodeName: string; + nodeType: string; + isTrigger: boolean; + /** Matched preset id for the node's current appearance (via `resolveFormTheme`), or `'custom'`. */ + preset: string; +} + +/** Current appearance of a single form node, resolved for the LLM. */ +export interface FormNodeAppearance { + nodeName: string; + nodeType: string; + /** Parsed CSS-variable overrides from the node's `options.customCss`. */ + overrides: Record; + appendAttribution: boolean; + /** Matched preset id (via `resolveFormTheme`), or `'custom'`. */ + preset: string; +} + +/** + * Reads and mutates the appearance/theming of form-trigger workflows for the + * Instance AI `forms` tool. Shaped so field/structure editing can be added + * later without reworking the surface — appearance-only for v1. + */ +export interface InstanceAiFormService { + /** Resolve a single form node's current appearance. When `nodeName` is + * omitted, the Form Trigger (or first form node) is used. `null` when the + * workflow has no form node. */ + getFormNode(workflowId: string, nodeName?: string): Promise; + /** List every form-capable node in the workflow. */ + listFormNodes(workflowId: string): Promise; + /** The workflow's display name — used to surface it as an artifact. `null` if not found. */ + getWorkflowName(workflowId: string): Promise; + /** Write assembled `customCss` + `appendAttribution` onto the target node(s). */ + applyAppearance( + workflowId: string, + params: { nodeNames: string[]; customCss: string; appendAttribution: boolean }, + ): Promise<{ updatedNodeNames: string[] }>; + /** Render a form node's appearance to HTML (optionally with proposed CSS). */ + renderPreview( + workflowId: string, + params: { nodeName?: string; customCss?: string; isCompletion?: boolean }, + ): Promise; +} + export interface ExecutionSummary { id: string; workflowId: string; @@ -1049,6 +1095,7 @@ export interface InstanceAiContext { */ modelId?: ModelConfig; workflowService: InstanceAiWorkflowService; + formService: InstanceAiFormService; executionService: InstanceAiExecutionService; credentialService: InstanceAiCredentialService; nodeService: InstanceAiNodeService; diff --git a/packages/cli/src/controllers/form-preview.controller.ts b/packages/cli/src/controllers/form-preview.controller.ts index cfb4d837153..9b0d703b2d0 100644 --- a/packages/cli/src/controllers/form-preview.controller.ts +++ b/packages/cli/src/controllers/form-preview.controller.ts @@ -1,56 +1,23 @@ import type { Response } from 'express'; -import { InstanceSettings } from 'n8n-core'; -import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow'; +import { type FormFieldsParameter } from 'n8n-workflow'; import { FormPreviewRequestDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { Body, Post, RestController } from '@n8n/decorators'; +import { FormPreviewService } from '@/services/form-preview.service'; + @RestController('/form-preview') export class FormPreviewController { - constructor(private readonly instanceSettings: InstanceSettings) {} + constructor(private readonly formPreviewService: FormPreviewService) {} + // Render markup is built by the shared FormPreviewService (reused by the Instance AI forms tool). @Post('/', { usesTemplates: true }) preview(_req: AuthenticatedRequest, res: Response, @Body body: FormPreviewRequestDto) { - if (body.isCompletion) { - const instanceId = this.instanceSettings.instanceId; - const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; - const completionCss = [ - body.customCss, - '.container { padding-bottom: var(--padding-container-top); }', - ] - .filter(Boolean) - .join('\n'); - const respondWith = body.respondWith ?? 'text'; - res.render('form-trigger-completion', { - title: body.formTitle, - message: body.formDescription, - formTitle: body.formTitle, - appendAttribution: body.appendAttribution ?? true, - n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`, - responseText: respondWith === 'showText' ? (body.responseText ?? '') : '', - responseBinary: encodeURIComponent(JSON.stringify('')), - dangerousCustomCss: completionCss, - redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined, - }); - return; - } - - const formFields = prepareFormFields(body.formFields as FormFieldsParameter); - const data = prepareFormData({ - formTitle: body.formTitle, - formDescription: body.formDescription, - formFields, - testRun: false, - query: {}, - instanceId: this.instanceSettings.instanceId, - buttonLabel: body.buttonLabel, - nodeVersion: body.nodeVersion, - customCss: body.customCss, - appendAttribution: body.appendAttribution ?? true, - formSubmittedText: undefined, - redirectUrl: undefined, + const { template, data } = this.formPreviewService.buildRenderInstruction({ + ...body, + formFields: body.formFields as FormFieldsParameter, }); - res.render('form-trigger', data); + res.render(template, data); } } diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index ce0d7d50ceb..09b139a3b0e 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -4,6 +4,9 @@ import path from 'node:path'; import type { InstanceAiContext, InstanceAiWorkflowService, + InstanceAiFormService, + FormNodeSummary, + FormNodeAppearance, InstanceAiExecutionService, InstanceAiCredentialService, InstanceAiNodeService, @@ -95,6 +98,7 @@ import { hasGlobalScope, type Scope } from '@n8n/permissions'; import { LessThan } from '@n8n/typeorm'; import { type ICredentialsDecrypted, + type FormFieldsParameter, type INode, type INodeParameters, type INodeProperties, @@ -113,6 +117,7 @@ import { Workflow, CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, + FORM_NODE_TYPE, WEBHOOK_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE, ManualExecutionCancelledError, @@ -123,6 +128,8 @@ import { jsonParse, createRunExecutionData, calculateWorkflowChecksum, + parseCssVariables, + resolveFormTheme, } from 'n8n-workflow'; import { ActiveExecutions } from '@/active-executions'; @@ -151,6 +158,7 @@ import { resolveBuiltinNodeDefinitionDirs } from '@/utils/node-definition-dirs'; import type { AiGatewayConfigDto } from '@n8n/api-types'; import { AiGatewayService } from '@/services/ai-gateway.service'; import { FolderService } from '@/services/folder.service'; +import { FormPreviewService, type FormPreviewInput } from '@/services/form-preview.service'; import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; @@ -340,6 +348,7 @@ export class InstanceAiAdapterService { projectId, modelId, workflowService: this.createWorkflowAdapter(user, threadId, projectId), + formService: this.createFormAdapter(user, threadId, projectId), executionService: this.createExecutionAdapter(user, pushRef, threadId), credentialService: this.createCredentialAdapter( user, @@ -586,6 +595,212 @@ export class InstanceAiAdapterService { return { getPersonalProjectId, assertProjectScope, resolveProjectId, resolveBoundProjectId }; } + /** + * Appearance/theming adapter for the Instance AI `forms` tool. Reads and + * mutates form-node `options.customCss` (RBAC-scoped to `user`) and renders + * previews via the shared `FormPreviewService` — the same render path the + * `/form-preview` controller uses. + */ + private createFormAdapter( + user: User, + threadId?: string, + _boundProjectId?: string, + ): InstanceAiFormService { + const { workflowFinderService, workflowService, workflowRepository, telemetry, nodeTypes } = + this; + const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows'); + const FORM_TYPES = new Set([FORM_TRIGGER_NODE_TYPE, FORM_NODE_TYPE]); + + // Defaults declared on the node type's `options` collection (e.g. the + // submit button's "Submit" label). The editor resolves these, so the + // preview must too — otherwise unset defaults render blank. + const optionDefaultsFor = (node: INode): Record => { + try { + const desc = nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description; + const collection = desc.properties.find( + (p): p is INodeProperties => p.name === 'options' && p.type === 'collection', + ); + const items = collection?.options as INodeProperties[] | undefined; + return Object.fromEntries(items?.map((p) => [p.name, p.default]) ?? []); + } catch { + return {}; + } + }; + + const loadFormNodes = async ( + workflowId: string, + scopes: Scope[], + ): Promise<{ workflow: WorkflowEntity; formNodes: INode[] }> => { + const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, scopes); + if (!workflow) throw new WorkflowNotFoundError(workflowId); + return { workflow, formNodes: workflow.nodes.filter((n) => FORM_TYPES.has(n.type)) }; + }; + + const nodeOptions = (node: INode): INodeParameters => + (node.parameters?.options as INodeParameters | undefined) ?? {}; + + // Resolve the target node: explicit name, else the Form Trigger, else first form node. + const resolveTargetNode = (formNodes: INode[], nodeName?: string): INode | undefined => + nodeName + ? formNodes.find((n) => n.name === nodeName) + : (formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE) ?? formNodes[0]); + + // Build the preview render input from a node's raw saved parameters, + // mirroring the editor's FormStepCard (options fall back to the trigger's). + const buildPreviewInput = ( + node: INode, + triggerNode: INode | undefined, + customCssOverride?: string, + ): FormPreviewInput => { + const params = (node.parameters ?? {}) as INodeParameters; + const options = nodeOptions(node); + const triggerParams = (triggerNode?.parameters ?? {}) as INodeParameters; + const triggerOptions = triggerNode ? nodeOptions(triggerNode) : {}; + const defaults = optionDefaultsFor(node); + const triggerDefaults = triggerNode ? optionDefaultsFor(triggerNode) : {}; + const customCss = customCssOverride ?? (options.customCss as string | undefined); + const appendAttribution = + (options.appendAttribution as boolean | undefined) ?? + (triggerOptions.appendAttribution as boolean | undefined) ?? + true; + + if (params.operation === 'completion') { + return { + isCompletion: true, + formTitle: (params.completionTitle as string) || '', + formDescription: (params.completionMessage as string) ?? '', + respondWith: (params.respondWith as string) || 'text', + responseText: (params.responseText as string) || '', + redirectUrl: (params.redirectUrl as string) || '', + nodeVersion: node.typeVersion, + customCss, + appendAttribution, + }; + } + + const isTrigger = node.type === FORM_TRIGGER_NODE_TYPE; + const formFields = + (params.formFields as { values?: INodeParameters[] } | undefined)?.values ?? []; + return { + formTitle: isTrigger + ? ((params.formTitle as string) ?? '') + : (options.formTitle as string) || (triggerParams.formTitle as string) || '', + formDescription: isTrigger + ? ((params.formDescription as string) ?? '') + : (options.formDescription as string) || (triggerParams.formDescription as string) || '', + buttonLabel: + (options.buttonLabel as string) || + (triggerOptions.buttonLabel as string) || + (defaults.buttonLabel as string) || + (triggerDefaults.buttonLabel as string) || + undefined, + formFields: formFields as unknown as FormFieldsParameter, + nodeVersion: node.typeVersion, + customCss, + appendAttribution, + }; + }; + + return { + async listFormNodes(workflowId: string): Promise { + const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']); + return formNodes.map((n) => { + const css = (nodeOptions(n).customCss as string | undefined) ?? ''; + return { + nodeName: n.name, + nodeType: n.type, + isTrigger: n.type === FORM_TRIGGER_NODE_TYPE, + preset: resolveFormTheme(css ? parseCssVariables(css) : {}), + }; + }); + }, + + async getWorkflowName(workflowId: string): Promise { + try { + const { workflow } = await loadFormNodes(workflowId, ['workflow:read']); + return workflow.name ?? null; + } catch { + return null; + } + }, + + async getFormNode(workflowId: string, nodeName?: string): Promise { + const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']); + const node = resolveTargetNode(formNodes, nodeName); + if (!node) return null; + + const options = nodeOptions(node); + const css = (options.customCss as string | undefined) ?? ''; + const overrides = css ? parseCssVariables(css) : {}; + const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE); + const triggerOptions = trigger ? nodeOptions(trigger) : {}; + const appendAttribution = + (options.appendAttribution as boolean | undefined) ?? + (triggerOptions.appendAttribution as boolean | undefined) ?? + true; + + return { + nodeName: node.name, + nodeType: node.type, + overrides, + appendAttribution, + preset: resolveFormTheme(overrides), + }; + }, + + async applyAppearance( + workflowId: string, + params: { nodeNames: string[]; customCss: string; appendAttribution: boolean }, + ): Promise<{ updatedNodeNames: string[] }> { + assertNotReadOnly(); + const { workflow } = await loadFormNodes(workflowId, ['workflow:update']); + const targets = new Set(params.nodeNames); + const updatedNodeNames: string[] = []; + + const nodes = workflow.nodes.map((n) => { + if (!targets.has(n.name) || !FORM_TYPES.has(n.type)) return n; + const opts = { + ...((n.parameters?.options as Record | undefined) ?? {}), + }; + if (params.customCss) opts.customCss = params.customCss; + else delete opts.customCss; + // Mirror the editor: only persist appendAttribution when disabled (default true). + if (!params.appendAttribution) opts.appendAttribution = false; + else delete opts.appendAttribution; + updatedNodeNames.push(n.name); + return { ...n, parameters: { ...n.parameters, options: opts as INodeParameters } }; + }); + + const updateData = workflowRepository.create({ nodes } as Partial); + await workflowService.update(user, updateData, workflowId, { source: 'n8n-ai' }); + + if (threadId) { + telemetry.track('Builder modified workflow', { + user_id: user.id, + thread_id: threadId, + workflow_id: workflowId, + }); + } + + return { updatedNodeNames }; + }, + + async renderPreview( + workflowId: string, + params: { nodeName?: string; customCss?: string; isCompletion?: boolean }, + ): Promise { + const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']); + const node = resolveTargetNode(formNodes, params.nodeName); + if (!node) { + throw new UserError(`No form node found in workflow ${workflowId}`); + } + const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE); + const input = buildPreviewInput(node, trigger, params.customCss); + return await Container.get(FormPreviewService).render(input); + }, + }; + } + private createWorkflowAdapter( user: User, threadId?: string, diff --git a/packages/cli/src/services/form-preview.service.ts b/packages/cli/src/services/form-preview.service.ts new file mode 100644 index 00000000000..f4e1e67edea --- /dev/null +++ b/packages/cli/src/services/form-preview.service.ts @@ -0,0 +1,111 @@ +import { Service } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; +import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow'; +import { join } from 'path'; + +import { TEMPLATES_DIR } from '@/constants'; +import { createHandlebarsEngine } from '@/utils/handlebars.util'; + +/** Normalized inputs for a form-trigger / form-completion preview render. */ +export interface FormPreviewInput { + formTitle?: string; + formDescription?: string; + formFields?: FormFieldsParameter; + buttonLabel?: string; + nodeVersion?: number; + customCss?: string; + appendAttribution?: boolean; + isCompletion?: boolean; + respondWith?: string; + responseText?: string; + redirectUrl?: string; +} + +type RenderInstruction = { template: string; data: Record }; + +/** + * The express-handlebars engine's published type describes its `options` arg as + * `ConfigOptions`, but at runtime it receives the template locals (exactly what + * express passes from `res.render`). We type the standalone call by its real + * runtime contract. + */ +type RenderEngine = ( + viewPath: string, + locals: Record, + callback: (err: unknown, rendered?: string) => void, +) => void; + +/** + * Renders the `form-trigger` / `form-trigger-completion` handlebars templates + * from unsaved node parameters. Shared between the REST `form-preview` + * controller (renders to the HTTP response) and the Instance AI forms adapter + * (renders to an HTML string) — the single source of truth for preview markup, + * mirroring how `NodeResourceExplorerService` backs both a REST path and a tool. + */ +@Service() +export class FormPreviewService { + private readonly engine = createHandlebarsEngine() as unknown as RenderEngine; + + constructor(private readonly instanceSettings: InstanceSettings) {} + + /** Build the template name + view data for a preview. Consumed by the + * controller (via `res.render`) and by `render()` below. */ + buildRenderInstruction(body: FormPreviewInput): RenderInstruction { + if (body.isCompletion) { + const instanceId = this.instanceSettings.instanceId; + const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; + const completionCss = [ + body.customCss, + '.container { padding-bottom: var(--padding-container-top); }', + ] + .filter(Boolean) + .join('\n'); + const respondWith = body.respondWith ?? 'text'; + return { + template: 'form-trigger-completion', + data: { + title: body.formTitle, + message: body.formDescription, + formTitle: body.formTitle, + appendAttribution: body.appendAttribution ?? true, + n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`, + responseText: respondWith === 'showText' ? (body.responseText ?? '') : '', + responseBinary: encodeURIComponent(JSON.stringify('')), + dangerousCustomCss: completionCss, + redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined, + }, + }; + } + + const formFields = prepareFormFields(body.formFields ?? []); + const data = prepareFormData({ + formTitle: body.formTitle ?? '', + formDescription: body.formDescription ?? '', + formFields, + testRun: false, + query: {}, + instanceId: this.instanceSettings.instanceId, + buttonLabel: body.buttonLabel, + nodeVersion: body.nodeVersion, + customCss: body.customCss, + appendAttribution: body.appendAttribution ?? true, + formSubmittedText: undefined, + redirectUrl: undefined, + }); + return { template: 'form-trigger', data: data as unknown as Record }; + } + + /** Render a preview to an HTML string (no HTTP response involved). */ + async render(body: FormPreviewInput): Promise { + const { template, data } = this.buildRenderInstruction(body); + const filePath = join(TEMPLATES_DIR, `${template}.handlebars`); + return await new Promise((resolve, reject) => { + // express-handlebars' engine is callable standalone; `settings.views` + // lets it resolve the templates dir without an express app. + this.engine(filePath, { ...data, settings: { views: TEMPLATES_DIR } }, (err, rendered) => { + if (err) reject(err instanceof Error ? err : new Error(String(err))); + else resolve(rendered ?? ''); + }); + }); + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c464ffbc824..3706c126542 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7053,6 +7053,10 @@ "instanceAi.workflowPreview.title": "Workflow preview", "instanceAi.workflowPreview.openInEditor": "Open in editor", "instanceAi.workflowPreview.fetchError": "Could not load workflow", + "instanceAi.formAppearance.title": "Apply form appearance?", + "instanceAi.formAppearance.summary": "{theme} · {scope}", + "instanceAi.formAppearance.scope.node": "{nodeName}", + "instanceAi.formAppearance.scope.workflow": "All form nodes", "instanceAi.dataTablePreview.title": "Data table preview", "instanceAi.dataTablePreview.open": "Open", "instanceAi.dataTablePreview.fetchError": "Could not load data table", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts index 00ab792ff1a..2ac7f098b0f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts @@ -306,6 +306,83 @@ describe('buildTimelineBlocks', () => { expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(4); }); + test('promotes a completed forms preview to its own standalone block', () => { + const blocks = blocksOf( + [reasoning('r1'), toolEntry('tc-1', 'r1')], + [ + makeToolCall({ + toolCallId: 'tc-1', + toolName: 'forms', + args: { action: 'preview' }, + result: { + previews: [{ previewHtml: '', nodeName: 'Attendee Details' }], + }, + }), + ], + ); + + // reasoning stays in a thinking block; the preview becomes its own block + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'form-preview']); + expect(blocks[1].type === 'form-preview' && blocks[1].toolCall.toolName).toBe('forms'); + }); + + test('keeps a forms preview inside the thinking block while it is still loading', () => { + const blocks = blocksOf( + [toolEntry('tc-1', 'r1')], + [ + makeToolCall({ + toolCallId: 'tc-1', + toolName: 'forms', + args: { action: 'preview' }, + isLoading: true, + result: undefined, + }), + ], + ); + + // No result yet → renders as a normal trace row, not promoted. + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe('thinking'); + }); + + test('does not promote non-preview forms actions', () => { + const blocks = blocksOf( + [toolEntry('tc-1', 'r1')], + [ + makeToolCall({ + toolCallId: 'tc-1', + toolName: 'forms', + args: { action: 'describe' }, + result: { node: { nodeName: 'x' } }, + }), + ], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe('thinking'); + }); + + test('promotes a multi-step forms preview (one block, rendered per step downstream)', () => { + const blocks = blocksOf( + [toolEntry('tc-1', 'r1')], + [ + makeToolCall({ + toolCallId: 'tc-1', + toolName: 'forms', + args: { action: 'preview' }, + result: { + previews: [ + { previewHtml: '1', nodeName: 'Attendee Details' }, + { previewHtml: '2', nodeName: 'Trip Preferences' }, + ], + }, + }), + ], + ); + + expect(blocks.map((b) => b.type)).toEqual(['form-preview']); + }); + test('text followed by same-response trace content joins the thinking block', () => { const blocks = blocksOf( [reasoning('r1'), text('Let me check the schema.', 'r1'), toolEntry('tc-1', 'r1')], diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts index b23d02eca94..1ca1d010e86 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts @@ -34,6 +34,7 @@ export type TimelineBlock = | { type: 'tasks'; key: string; toolCall: InstanceAiToolCallState } | { type: 'plan-review'; key: string; toolCall: InstanceAiToolCallState } | { type: 'questions'; key: string; toolCall: InstanceAiToolCallState } + | { type: 'form-preview'; key: string; toolCall: InstanceAiToolCallState } | { type: 'child'; key: string; child: InstanceAiAgentNode }; type ToolCallKind = @@ -42,8 +43,21 @@ type ToolCallKind = | 'plan-review' | 'questions' | 'questions-pending' + | 'form-preview' | 'trace'; +/** A completed `forms` preview carries rendered HTML (one entry per form step) + * we surface as its own prominent block instead of burying it in the collapsed + * thinking trace. */ +function isFormPreview(tc: InstanceAiToolCallState): boolean { + if (tc.toolName !== 'forms') return false; + if ((tc.args as Record | undefined)?.action !== 'preview') return false; + const result = tc.result; + if (!result || typeof result !== 'object') return false; + const previews = (result as Record).previews; + return Array.isArray(previews) && previews.length > 0; +} + /** * How a tool call renders in the timeline. `trace` rows join thinking blocks; * `tasks`/`plan-review`/`questions` render standalone UI; `hidden` calls are @@ -55,6 +69,7 @@ type ToolCallKind = */ function classifyToolCall(tc: InstanceAiToolCallState): ToolCallKind { if (HIDDEN_TOOLS.has(tc.toolName)) return 'hidden'; + if (isFormPreview(tc)) return 'form-preview'; if (tc.renderHint === 'tasks') return 'tasks'; if (tc.renderHint === 'builder' && tc.toolName.endsWith('-with-agent')) return 'hidden'; if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return 'hidden'; @@ -192,6 +207,9 @@ export function buildTimelineBlocks( case 'questions': pushStandalone({ type: 'questions', key: `questions-${idx}`, toolCall: tc }); return; + case 'form-preview': + pushStandalone({ type: 'form-preview', key: `form-preview-${idx}`, toolCall: tc }); + return; case 'trace': pushTrace(entry, idx); return; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue index 5d343378dce..530faa2e662 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue @@ -23,6 +23,7 @@ import PlanReviewPanel, { type PlannedTaskArg, type PlanReviewStatus } from './P import TaskChecklist from './TaskChecklist.vue'; import ThinkingBlock from './ThinkingBlock.vue'; import TimelineTextSegment from './TimelineTextSegment.vue'; +import InstanceAiFormPreview from './InstanceAiFormPreview.vue'; const i18n = useI18n(); const thread = useThread(); @@ -232,6 +233,27 @@ function handlePlanDeny(tc: InstanceAiToolCallState) { void thread.confirmAction(requestId, { kind: 'planDeny' }); } +/** Map simplified TaskList items to PlannedTaskArg shape for loading preview */ +/** Extract the rendered previews (one per form step) from a `forms` preview call. */ +function getFormPreviews( + tc: InstanceAiToolCallState, +): Array<{ previewHtml: string; themeLabel?: string; nodeName?: string }> { + const result = (tc.result ?? {}) as Record; + const previews = Array.isArray(result.previews) ? result.previews : []; + return previews.flatMap((p) => { + if (!p || typeof p !== 'object') return []; + const r = p as Record; + if (typeof r.previewHtml !== 'string') return []; + return [ + { + previewHtml: r.previewHtml, + ...(typeof r.themeLabel === 'string' ? { themeLabel: r.themeLabel } : {}), + ...(typeof r.nodeName === 'string' ? { nodeName: r.nodeName } : {}), + }, + ]; + }); +} + /** Map simplified TaskList items to PlannedTaskArg shape for loading preview */ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefined { if (!tasks?.tasks?.length) return undefined; @@ -284,6 +306,17 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormAppearance.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormAppearance.vue new file mode 100644 index 00000000000..2af64ea5a25 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormAppearance.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormPreview.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormPreview.vue new file mode 100644 index 00000000000..d8996975e25 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiFormPreview.vue @@ -0,0 +1,90 @@ + + +