From d765bbd68e9d4ee8aef219b4a68bc33bb3afaece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Mon, 28 Sep 2026 13:34:11 +0000 Subject: [PATCH] feat: Add an n8n Assistant onboarding thread for new Cloud signups (#39730) --- packages/@n8n/api-types/src/index.ts | 2 + .../src/schemas/instance-ai.schema.ts | 31 +- .../skills/suggest-automations/SKILL.md | 121 ++++++ .../@n8n/instance-ai/src/stream/map-chunk.ts | 12 +- packages/@n8n/instance-ai/src/tools/index.ts | 13 + .../src/tools/shared/ask-user.tool.ts | 8 + .../src/tools/shared/leave-onboarding.tool.ts | 45 ++ .../@n8n/instance-ai/src/tools/tool-ids.ts | 4 + packages/@n8n/instance-ai/src/types.ts | 2 + .../@n8n/telemetry/src/events/instance-ai.ts | 65 +++ .../__tests__/instance-ai.controller.test.ts | 53 +++ .../__tests__/instance-ai.service.test.ts | 10 +- .../instance-ai/__tests__/onboarding.test.ts | 195 +++++++++ .../instance-ai/instance-ai-memory.service.ts | 46 +- .../instance-ai.adapter.service.ts | 4 + .../instance-ai/instance-ai.controller.ts | 39 +- .../instance-ai/instance-ai.service.ts | 24 +- .../modules/instance-ai/internal-messages.ts | 60 ++- .../modules/instance-ai/onboarding-opening.ts | 77 ++++ .../cli/src/modules/instance-ai/onboarding.ts | 407 ++++++++++++++++++ .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../src/app/layouts/InstanceAiLayout.vue | 12 +- .../ai/instanceAi/InstanceAiThreadView.vue | 14 +- .../__tests__/InstanceAiQuestions.test.ts | 22 + .../__tests__/agentTimeline.utils.test.ts | 14 + .../__tests__/instanceAi.store.test.ts | 38 ++ .../instanceAi.threadRuntime.test.ts | 50 +++ .../ai/instanceAi/agentTimeline.utils.ts | 6 +- .../components/AnsweredQuestions.vue | 3 +- .../InstanceAiConfirmationPanel.vue | 1 + .../components/InstanceAiConversation.vue | 127 +++++- .../components/InstanceAiQuestions.vue | 75 +++- .../composables/useInstanceAiHandoff.ts | 33 ++ .../ai/instanceAi/instanceAi.store.ts | 48 ++- .../ai/instanceAi/instanceAi.threadRuntime.ts | 37 ++ .../ai/instanceAi/module.descriptor.ts | 46 ++ 36 files changed, 1684 insertions(+), 61 deletions(-) create mode 100644 packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md create mode 100644 packages/@n8n/instance-ai/src/tools/shared/leave-onboarding.tool.ts create mode 100644 packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts create mode 100644 packages/cli/src/modules/instance-ai/onboarding-opening.ts create mode 100644 packages/cli/src/modules/instance-ai/onboarding.ts diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 344ba5b4d5e..9f49a858eb6 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -362,6 +362,7 @@ export { } from './schemas/credential-response.schema'; export { + instanceAiQuestionSchema, buildRunWorkflowSessionGrantKey, buildExecuteNodeSessionGrantKey, buildRunStepSessionGrantKey, @@ -570,6 +571,7 @@ export type { InstanceAiApprovalResumeData, InstanceAiCredentialPlaceholderDef, InstanceAiTargetApproval, + InstanceAiQuestion, InstanceAiCredentialRequest, InstanceAiCredentialSetupHint, InstanceAiAgentStatus, 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 6006639cc3c..7a8d36d0fd2 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -835,6 +835,26 @@ export const instanceAiTargetApprovalSchema = z.object({ }); export type InstanceAiTargetApproval = z.infer; +/** One question of the ask-user card (`inputType=questions`). */ +export const instanceAiQuestionSchema = z.object({ + id: z.string(), + question: z.string(), + type: z.enum(['single', 'multi', 'text']), + options: z.array(z.string()).optional(), + /** Hides Skip and blocks Next until the question has an answer. */ + required: z.boolean().optional(), + /** Label of the built-in free-text row; the card shows "Something else" without it. */ + freeTextLabel: z.string().optional(), + /** + * Options that follow an earlier `single` question of the same card: the option selected for + * `questionId` picks the list. A free-text or unknown answer falls back to `options`. + */ + optionsByAnswer: z + .object({ questionId: z.string(), options: z.record(z.string(), z.array(z.string())) }) + .optional(), +}); +export type InstanceAiQuestion = z.infer; + export const confirmationRequestPayloadSchema = z.object({ requestId: z.string(), inputThreadId: z @@ -878,14 +898,7 @@ export const confirmationRequestPayloadSchema = z.object({ 'continue shows a single primary button (used by pause-for-user)', ), questions: z - .array( - z.object({ - id: z.string(), - question: z.string(), - type: z.enum(['single', 'multi', 'text']), - options: z.array(z.string()).optional(), - }), - ) + .array(instanceAiQuestionSchema) .optional() .describe('Structured questions for the Q&A wizard (inputType=questions)'), introMessage: z.string().optional().describe('Intro text shown above questions or plan review'), @@ -1750,6 +1763,7 @@ export class InstanceAiCorrectTaskRequest extends Z.class({ * - `agent_builder_page` — Instance AI hand-off from the agent builder * - `agent_preview` — send a preview chat session to Instance AI * - `assistant_page` — first message typed on the Instance AI empty/home page + * - `onboarding` — seeded "Welcome to n8n" thread for a new user; the greeting is stored before the first user turn * - `evals` — Instance AI evaluation harness / offline eval runners * - `playwright` — Playwright E2E helpers that create threads via the REST API * Experiment cleanup: remove with openWorkflowInAssistant. @@ -1767,6 +1781,7 @@ export const INSTANCE_AI_THREAD_SOURCES = [ 'agent_builder_page', 'agent_preview', 'assistant_page', + 'onboarding', // Experiment cleanup: remove with openWorkflowInAssistant. 'workflow_list_auto', 'workflow_list_button', diff --git a/packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md b/packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md new file mode 100644 index 00000000000..64d3ff82902 --- /dev/null +++ b/packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md @@ -0,0 +1,121 @@ +--- +name: suggest-automations +description: >- + Offer the three most common automations for a user as a single-choice card, + from your own knowledge of their team and their apps, then build the one + they choose or offer more. The system preloads it on onboarding threads, + where the opening card already asked the user about themselves. + Use it on other threads when the user asks what they could automate or + wants ideas for a first workflow. +recommended_tools: + - ask-user + - build-workflow +--- + +# Suggest automations + +## Input + +You need to know who the user is, at least their team and the apps they use. +The user knows little about n8n: no n8n vocabulary, and no questions about +tasks or pain points. + +On an onboarding thread the conversation already holds an `` +block with the user's team and apps, one line per question. Use every line of +the block. The answers are final: use them as they come and do not ask these +questions again. Two openings: + +- The block is followed by your message "Got it! Tell me a little about how + you use ." and the user's reply. The reply describes how they + work, names a task in their own words, or names none. Start at step 1. +- The block is the latest message and one answer is free text the user typed + into the card. Read that text first. A tool name is one of their apps: ask + "Got it! Tell me a little about how you use ." and end the + turn. A task: start at step 1 with it. Anything else, for example a wish to + stop, a request about something else, a refusal or an insult: call + `leave-onboarding`, then write a one-sentence reply that invites the user to + explore the app and to come back with a task. + +On any other thread, ask in ONE `ask-user` call: a `single` question "What +team are you on?" with the options Executive/Owner, Support, Product & Design, +Sales, IT, Engineering and Marketing, and a `multi` question "Which apps do you +use?" with the ten apps people use most at work, as product names such as +Slack or Google Sheets. Never run a command or read a file to find them. + +## Flow + +1. Pick three automations that each solve a different problem, ordered from + the most to the least relevant. Each one runs on the user's apps and is + realistic for n8n. When the user named a task, even a specific and + complete one, suggestion one is that task in their words and the other two + are what people on their team set up next to it. Otherwise pick the three + that people on the team most often set up with the user's apps. The card + always comes before a build: a specific task gets confirmed with one click, + never built unasked. Decide in one step: do not run a command, read a file + or write the reasoning out. +2. Write three sentences of text and no list: the card carries the options. + First reflect the task in the user's words, for example "If I got you + right, you want to capture leads from Gmail in HubSpot and make sure the + sales team follows up." Then one sentence of social proof about people on + the user's team, from your own knowledge, for example "Sales teams often + start with exactly this." Then, after a blank line, "Here are three + initial ways n8n could help:". Without a task, drop the first sentence + and put the team and apps into the social proof: "Sales teams that use + Gmail and Slack most often automate their lead follow-up." Never claim + that you looked at workflows, templates or usage data: you have not. + Then ONE `ask-user` call with `questions` only: a `single` + question "Which one feels like the best fit?", `required: true`, with + four options: the three suggestions, most relevant first, and last + `Show me other ideas`. `Show me other ideas` is an action that asks for + three more suggestions, not a catch-all: always keep it as the fourth + option. Leave `introMessage` out. Set `freeTextLabel` to `Share more + context`: the card shows it as its built-in free-text row with a pencil + icon. Never add "Something else", "Share more context" or "None of these" + as an option. + + A suggestion is one option string of two lines: the title, a newline, + then the description. The card shows the second line under the first. + - Title: a short action phrase of at most 6 words that names the result, + not the setup, for example "Capture new leads" or "Speed up customer + follow-ups". Not a sentence, and no punctuation at the end. + - Description: one plain sentence of at most 18 words about what the user + gets. State no trigger, schedule, recipient or other process detail the + user did not give. No Markdown, no n8n node names. + - Apps: name only apps the user selected or named, at most two in one + suggestion, and only where the name makes the suggestion clearer. With + no app or one app from the user, describe the result without app names. + Example option: "Capture new leads\nMove new enquiries into your sales + process so nothing gets missed." +3. Read the answer. + - A suggestion: its first line, the title, names the automation to build. + - `Show me other ideas`: repeat step 2 with three automations not shown in + this thread yet, after one sentence such as "Here are three more." + - Free text that describes a task: that is the user's request; build it. + - Free text that asks for a change, for example another app or a topic: + repeat step 2 with three automations that fit it. + - Free text that names no task and no change, for example "skip", "no" or + "later", or `answered: false`: do not build. On an onboarding thread call + `leave-onboarding`, then write a one-sentence reply, for example "No + problem. Explore the app and tell me when you want to automate something." +4. Write exactly one line before the first tool call, `Building now.`, + and no other text until the `build-workflow` result. Load `workflow-builder` + and build the automation with the user's apps the normal way, then follow + `postBuildFlow.instructions` from the result. Ask about an app only for a + step the user's list does not cover, one question per turn. + +## Rules + +- One `ask-user` call per turn. Keep every message under four sentences. +- A build needs a picked suggestion or a task in the user's words. Never + start one after a skip, a dismissal, or free text that names nothing. +- No apps given: suggest what people on the team most often automate, and + name no app. +- Talk about the automation, never about the mechanics. Do not say + "validate", "workspace" or a file name in a message. Say what is being + built and what the user gets, for example "Building the reminder now." +- If the user wants to stop the onboarding or asks for something unrelated, + drop this flow. On an onboarding thread, whenever you stop asking the card + questions, call `leave-onboarding` first and then reply. A reply that skips + the questions without that call leaves the user stuck in the flow. +- Never ask for credentials, keys, or passwords in chat. +- Reply in the language the user writes in. diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index 83b7c2e192d..5d0693608db 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -1,6 +1,7 @@ import { APPROVAL_SUSPEND_SCHEMA, type StreamChunk } from '@n8n/agents'; import { instanceAiApprovalDetailsSchema, + instanceAiQuestionSchema, credentialRequestSchema, workflowSetupNodeSchema, taskListSchema, @@ -13,19 +14,12 @@ import { } from '@n8n/api-types'; import type { InstanceAiEvent } from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; -import { z } from 'zod'; +import type { z } from 'zod'; import { isQuotaExhaustedError, QUOTA_EXHAUSTED_ERROR_CODE } from '../utils/quota-error'; export { isQuotaExhaustedError, QUOTA_EXHAUSTED_ERROR_CODE } from '../utils/quota-error'; -const questionItemSchema = z.object({ - id: z.string(), - question: z.string(), - type: z.enum(['single', 'multi', 'text']), - options: z.array(z.string()).optional(), -}); - function getArrayProperty(record: Record<string, unknown>, key: string): unknown[] | undefined { const value = record[key]; return Array.isArray(value) ? value : undefined; @@ -334,7 +328,7 @@ function mapSuspendedChunk( const requireUserSelection = suspendPayload.requireUserSelection === true; const projectId = presentString(suspendPayload.projectId); const inputType = parseInputType(suspendPayload.inputType); - const questions = parseSchemaArray(suspendPayload.questions, questionItemSchema); + const questions = parseSchemaArray(suspendPayload.questions, instanceAiQuestionSchema); const introMessage = presentString(suspendPayload.introMessage); const tasks = parseSchemaRecord(suspendPayload.tasks, taskListSchema); const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema); diff --git a/packages/@n8n/instance-ai/src/tools/index.ts b/packages/@n8n/instance-ai/src/tools/index.ts index fc20e5e9895..34ac2b615ce 100644 --- a/packages/@n8n/instance-ai/src/tools/index.ts +++ b/packages/@n8n/instance-ai/src/tools/index.ts @@ -78,6 +78,10 @@ const loadResearchTool = lazyMod( const loadAskUserTool = lazyMod( () => require('./shared/ask-user.tool') as typeof import('./shared/ask-user.tool'), ); +const loadLeaveOnboardingTool = lazyMod( + () => + require('./shared/leave-onboarding.tool') as typeof import('./shared/leave-onboarding.tool'), +); const loadTaskControlTool = lazyMod( () => require('./task-control.tool') as typeof import('./task-control.tool'), ); @@ -118,6 +122,15 @@ function getOrchestratorDomainToolFactories( ], ]; + // Onboarding threads only: the tool ends the flow the host seeded, and the frontend restores + // the chat chrome when it sees the call. + if (context.onboardingThread) { + tools.push([ + DOMAIN_TOOL_IDS.LEAVE_ONBOARDING, + () => loadLeaveOnboardingTool().createLeaveOnboardingTool(), + ]); + } + // eval-config is flag-gated: the adapter only wires evaluationConfigService // when `088_config_evaluations` is on, so presence = expose the tool. if (context.evaluationConfigService) { diff --git a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts index f87a9b72dfc..dae95685998 100644 --- a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts @@ -18,6 +18,14 @@ const questionSchema = z.object({ .array(z.string()) .optional() .describe('Suggested answers (required for single/multi, ignored for text)'), + required: z + .boolean() + .optional() + .describe('true hides Skip: the user must answer before they can submit'), + freeTextLabel: z + .string() + .optional() + .describe('Label of the built-in free-text row, "Something else" by default'), }); const answerSchema = z.object({ diff --git a/packages/@n8n/instance-ai/src/tools/shared/leave-onboarding.tool.ts b/packages/@n8n/instance-ai/src/tools/shared/leave-onboarding.tool.ts new file mode 100644 index 00000000000..cb38f1e79a9 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/shared/leave-onboarding.tool.ts @@ -0,0 +1,45 @@ +import { Tool } from '@n8n/agents'; +import { z } from 'zod'; + +import { DOMAIN_TOOL_IDS } from '../tool-ids'; + +/** + * The agent's explicit exit from the onboarding flow. The host reacts to the tool call + * itself and restores the normal chat chrome. The result tells the agent that the + * onboarding rules end here: the skill block stays in context for the rest of the turn, + * and its card-only questions would otherwise leak into the help that follows. + */ +export function createLeaveOnboardingTool() { + return ( + new Tool(DOMAIN_TOOL_IDS.LEAVE_ONBOARDING) + .description( + 'End the onboarding flow. Call it when the user wants to stop the onboarding, explore n8n ' + + 'on their own, or asks for something unrelated to picking a first automation. After the call, ' + + 'reply with one sentence that invites the user to explore the app and to come back with a ' + + 'task, then help them with what they asked. Not needed before a build: a build ends the ' + + 'onboarding by itself.', + ) + .input( + z.object({ + // ponytail: optional, so a call without a reason still ends the onboarding. + reason: z + .enum(['stop', 'explore', 'unrelated_request']) + .optional() + .describe( + 'Why the onboarding ends: the user wants to stop, wants to explore n8n on their ' + + 'own, or asked for something unrelated to picking a first automation.', + ), + }), + ) + .output(z.object({ left: z.boolean(), note: z.string() })) + // ponytail: the frontend persists the exit (thread metadata `onboardingLeft`) when it sees + // this call. Persist it here once the backend must know without a connected client. + .handler(async () => ({ + left: true, + note: + 'The onboarding rules no longer apply. Continue as on a normal thread. Ask for files ' + + 'in plain text: a card cannot take attachments.', + })) + .build() + ); +} diff --git a/packages/@n8n/instance-ai/src/tools/tool-ids.ts b/packages/@n8n/instance-ai/src/tools/tool-ids.ts index ad4b22f520a..4526153c1a1 100644 --- a/packages/@n8n/instance-ai/src/tools/tool-ids.ts +++ b/packages/@n8n/instance-ai/src/tools/tool-ids.ts @@ -12,6 +12,7 @@ export const DOMAIN_TOOL_IDS = { NODES: 'nodes', SEARCH_MODELS: 'searchModels', ASK_USER: 'ask-user', + LEAVE_ONBOARDING: 'leave-onboarding', BUILD_WORKFLOW: 'build-workflow', PARSE_FILE: 'parse-file', AGENT_CONTEXT: 'agent-context', @@ -56,6 +57,9 @@ export const ORCHESTRATION_TOOL_NAMES = new Set<string>(Object.values(ORCHESTRAT export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([ DOMAIN_TOOL_IDS.ASK_USER, + // Registered on onboarding threads only, so every other thread pays nothing for the entry. + // Deferring it would price the user's "let me out" at search_tools + load_tool. + DOMAIN_TOOL_IDS.LEAVE_ONBOARDING, DOMAIN_TOOL_IDS.CREDENTIALS, DOMAIN_TOOL_IDS.WORKFLOWS, DOMAIN_TOOL_IDS.EXECUTIONS, diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 58c75089f86..1272c8f6d27 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -1576,6 +1576,8 @@ export interface InstanceAiContext { * and rows carry `folder`. Absent or false keeps the pre-feature shape. */ folderExplorationEnabled?: boolean; + /** True while the thread runs the host-seeded onboarding flow. Presence gates `leave-onboarding`. */ + onboardingThread?: boolean; /** * Host-resolved model for the current run (proxy-managed on cloud). Domain * tools pass it as the fallback for utility LLM calls (simulation fixtures, diff --git a/packages/@n8n/telemetry/src/events/instance-ai.ts b/packages/@n8n/telemetry/src/events/instance-ai.ts index 73d685d9e29..5a3e2404c83 100644 --- a/packages/@n8n/telemetry/src/events/instance-ai.ts +++ b/packages/@n8n/telemetry/src/events/instance-ai.ts @@ -570,6 +570,71 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({ total: z.number().int().describe('Rows matching every filter, ignoring limit'), }), }, + USER_STARTED_AI_ASSISTANT_ONBOARDING: { + name: 'User started AI Assistant onboarding', + description: + 'An onboarding thread was created: the user arrived from the n8n Cloud signup survey (/assistant?source=onboarding) or opened the onboarding by hand. Fires once per thread, from the backend, when the seeded greeting and question card are stored.', + properties: z.object({ + user_id: z.string(), + thread_id: z.string(), + team: z + .string() + .nullable() + .describe( + 'The signup survey answer to "What team are you on?". Null when the survey did not answer it, so the card asks', + ), + team_source: z + .enum(['cloud', 'url']) + .nullable() + .describe( + 'Where the team answer came from: the n8n Cloud account, or the `?team=` query of a test run. Null when there is no team', + ), + }), + }, + USER_ANSWERED_AI_ASSISTANT_ONBOARDING_CARD: { + name: 'User answered AI Assistant onboarding card', + description: + 'The user settled the question card an onboarding thread opens with. Fires once per thread, from the backend, when the answers are stored. Carries the picked apps and the free text as typed.', + properties: z.object({ + user_id: z.string(), + thread_id: z.string(), + team: z + .string() + .nullable() + .describe('From the signup survey or the card. Null when the card step was skipped'), + team_source: z + .enum(['cloud', 'url', 'card']) + .nullable() + .describe( + 'Where the team came from: the n8n Cloud account, the `?team=` query of a test run, or the card. Null when the card step was skipped', + ), + apps: z.array(z.string()).describe('Apps picked in the card, by their shown names'), + custom_text: z + .string() + .nullable() + .describe('Free text typed in the card, which starts the first model turn'), + }), + }, + AI_ASSISTANT_ONBOARDING_ENDED: { + name: 'AI Assistant onboarding ended', + description: + 'The onboarding flow of a thread ended and the normal chat came back. Fires once per thread, from the browser, on the first build, when the agent calls `leave-onboarding`, or when a run fails.', + properties: z.object({ + thread_id: z.string(), + instance_id: z.string(), + outcome: z + .enum(['build', 'left', 'run_failed']) + .describe( + 'build: the agent started the first workflow. left: the agent called `leave-onboarding`. run_failed: the run ended with an error or was interrupted', + ), + leave_reason: z + .string() + .nullable() + .describe( + "The reason the agent gave on `leave-onboarding`: 'stop', 'explore' or 'unrelated_request'. Null for the other outcomes, or when the agent gave none", + ), + }), + }, INSTANCE_CONTEXT_TURN: { name: 'Instance AI instance-context turn completed', description: diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index f951d7050f3..82a3b97823b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -93,6 +93,7 @@ import type { InProcessEventBus } from '../event-bus/in-process-event-bus'; import type { LocalGateway } from '../filesystem/local-gateway'; import type { InstanceAiGatewayService } from '../instance-ai-gateway.service'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; +import type { InstanceAiOnboardingService } from '../onboarding'; import type { InstanceAiPendingAgentService } from '../instance-ai-pending-agent.service'; import type { InstanceAiPreferenceCardService } from '../instance-ai-preference-card.service'; import type { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; @@ -144,12 +145,14 @@ describe('InstanceAiController', () => { const evalCredentialAllowlists = new EvalThreadCredentialAllowlistService(); const evalThreadRestore = mock<EvalThreadRestoreService>(); const preferenceCardService = mock<InstanceAiPreferenceCardService>(); + const onboarding = mock<InstanceAiOnboardingService>(); const controller = new InstanceAiController( instanceAiService, gatewayService, browserSessionService, memoryService, + onboarding, pendingAgentService, settingsService, modelCatalogService, @@ -1705,6 +1708,55 @@ describe('InstanceAiController', () => { await expect(controller.confirm(reqWithBody, res, 'req-1')).rejects.toThrow(NotFoundError); }); + + it('should check the model before a free-text answer consumes the onboarding card', async () => { + settingsService.isModelConfigured.mockResolvedValue(false); + const body: InstanceAiConfirmRequest = { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: [], customText: 'import a csv' }], + }; + const reqWithBody = { ...req, body } as AuthenticatedRequest; + + await expect(controller.confirm(reqWithBody, res, 'onboarding-card')).rejects.toMatchObject({ + message: expect.stringContaining('no model configured'), + }); + expect(onboarding.answerCard).not.toHaveBeenCalled(); + }); + + it('should start the first turn with the answers when the onboarding card holds free text', async () => { + onboarding.answerCard.mockResolvedValue({ threadId: 'thread-1', firstMessage: 'answers' }); + instanceAiService.startRun.mockReturnValue('run-2'); + const body: InstanceAiConfirmRequest = { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: [], customText: 'import a csv' }], + }; + const reqWithBody = { ...req, body } as AuthenticatedRequest; + + const result = await controller.confirm(reqWithBody, res, 'onboarding-card'); + + expect(result).toEqual({ ok: true, runId: 'run-2' }); + expect(instanceAiService.startRun).toHaveBeenCalledWith( + reqWithBody.user, + 'thread-1', + 'answers', + ); + expect(instanceAiService.resolveConfirmation).not.toHaveBeenCalled(); + }); + + it('should skip the model check and return the follow-up run when the onboarding card holds no free text', async () => { + settingsService.isModelConfigured.mockResolvedValue(false); + onboarding.answerCard.mockResolvedValue({ threadId: 'thread-1', runId: 'run-3' }); + const body: InstanceAiConfirmRequest = { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: ['Gmail'] }], + }; + const reqWithBody = { ...req, body } as AuthenticatedRequest; + + const result = await controller.confirm(reqWithBody, res, 'onboarding-card'); + + expect(result).toEqual({ ok: true, runId: 'run-3' }); + expect(instanceAiService.startRun).not.toHaveBeenCalled(); + }); }); describe('getAdminSettings', () => { @@ -2654,6 +2706,7 @@ describe('InstanceAiController — durable-log SSE replay', () => { mock<InstanceAiGatewayService>(), mock<InstanceAiBrowserSessionService>(), memoryService, + mock<InstanceAiOnboardingService>(), mock<InstanceAiPendingAgentService>(), settingsService, mock<InstanceAiModelCatalogService>(), diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 40661a94a2b..6e83cff2119 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -951,7 +951,10 @@ describe('InstanceAiService — runtime workspace setup', () => { resolveProxyModel: vi.fn(async () => 'model-1'), }; service.ensureThreadExists = vi.fn(async () => {}); - service.agentMemory = { getThreadProjectId: vi.fn(async () => 'project-1') }; + service.agentMemory = { + getThreadProjectId: vi.fn(async () => 'project-1'), + getThread: vi.fn(async () => undefined), + }; service.dbIterationLogStorage = {}; service.checkpointStore = {}; service.instanceAiConfig = {}; @@ -1302,7 +1305,10 @@ describe('InstanceAiService — runtime workspace setup', () => { resolveProxyModel: vi.fn(async () => 'model-1'), }; service.ensureThreadExists = vi.fn(async () => {}); - service.agentMemory = { getThreadProjectId: vi.fn(async () => 'project-1') }; + service.agentMemory = { + getThreadProjectId: vi.fn(async () => 'project-1'), + getThread: vi.fn(async () => undefined), + }; service.dbIterationLogStorage = {}; service.dbSnapshotStorage = {}; service.checkpointStore = {}; diff --git a/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts b/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts new file mode 100644 index 00000000000..3510f3ad6ef --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/onboarding.test.ts @@ -0,0 +1,195 @@ +import type { InstanceAiConfirmRequest } from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { Telemetry } from '@/telemetry'; + +import type { InstanceAiPendingConfirmation } from '../entities/instance-ai-pending-confirmation.entity'; +import type { DurableEventLog } from '../event-bus/durable-event-log'; +import type { InProcessEventBus } from '../event-bus/in-process-event-bus'; +import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; +import { InstanceAiOnboardingService, startsOnboardingFirstTurn } from '../onboarding'; +import { ONBOARDING_OPENING } from '../onboarding-opening'; +import type { InstanceAiPendingConfirmationRepository } from '../repositories/instance-ai-pending-confirmation.repository'; + +const user = mock<User>({ id: 'user-1', firstName: 'Ada' }); +const THREAD_ID = 'thread-1'; +const CARD_REQUEST_ID = 'onboarding-card'; +const urlSurvey = { survey: { what_team_are_you_on: 'Marketing' }, surveySource: 'url' }; + +function setup(sourceContext?: Record<string, unknown>) { + const memoryService = mock<InstanceAiMemoryService>(); + const pendingConfirmationRepo = mock<InstanceAiPendingConfirmationRepository>(); + const telemetry = mock<Telemetry>(); + const eventBus = mock<InProcessEventBus>(); + memoryService.ensureThread.mockResolvedValue({ + thread: { + id: THREAD_ID, + resourceId: user.id, + createdAt: '2026-09-28T00:00:00.000Z', + updatedAt: '2026-09-28T00:00:00.000Z', + }, + created: true, + }); + memoryService.seedOpeningMessages.mockResolvedValue({ userMessageId: 'msg-1' }); + memoryService.getThreadMetadata.mockResolvedValue({ source: 'onboarding', sourceContext }); + pendingConfirmationRepo.claim.mockResolvedValue( + mock<InstanceAiPendingConfirmation>({ + threadId: THREAD_ID, + runId: 'run-1', + toolCallId: 'tc-1', + }), + ); + const service = new InstanceAiOnboardingService( + memoryService, + eventBus, + mock<DurableEventLog>(), + pendingConfirmationRepo, + telemetry, + ); + return { service, telemetry, memoryService, pendingConfirmationRepo, eventBus }; +} + +describe('InstanceAiOnboardingService telemetry', () => { + it('tracks the start with the team and where the team came from', async () => { + const { service, telemetry } = setup(); + + await service.ensureThread(user, THREAD_ID, 'project-1', { + source: 'onboarding', + origin: 'external', + sourceContext: urlSurvey, + }); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.INSTANCE_AI.USER_STARTED_AI_ASSISTANT_ONBOARDING, + { user_id: user.id, thread_id: THREAD_ID, team: 'Marketing', team_source: 'url' }, + ); + }); + + it('tracks the card answers with the card as the team source', async () => { + const { service, telemetry } = setup(); + + await service.answerCard(user.id, CARD_REQUEST_ID, { + kind: 'questions', + answers: [ + { questionId: 'team', selectedOptions: ['Sales'] }, + { questionId: 'apps', selectedOptions: ['Gmail', 'Slack'] }, + ], + }); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.INSTANCE_AI.USER_ANSWERED_AI_ASSISTANT_ONBOARDING_CARD, + { + user_id: user.id, + thread_id: THREAD_ID, + team: 'Sales', + team_source: 'card', + apps: ['Gmail', 'Slack'], + custom_text: null, + }, + ); + }); + + it('keeps the survey as the team source when the survey answered the team step', async () => { + const { service, telemetry } = setup(urlSurvey); + + await service.answerCard(user.id, CARD_REQUEST_ID, { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: ['Gmail'] }], + }); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.INSTANCE_AI.USER_ANSWERED_AI_ASSISTANT_ONBOARDING_CARD, + expect.objectContaining({ team: 'Marketing', team_source: 'url', apps: ['Gmail'] }), + ); + }); +}); + +const freeTextAnswer: InstanceAiConfirmRequest = { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: [], customText: 'i just want to import a csv' }], +}; +const approval: InstanceAiConfirmRequest = { kind: 'approval', approved: true }; + +describe('InstanceAiOnboardingService answerCard', () => { + it('refuses an answer of another kind before the card is claimed', async () => { + const { service, pendingConfirmationRepo, eventBus } = setup(); + + await expect(service.answerCard(user.id, CARD_REQUEST_ID, approval)).rejects.toThrow( + BadRequestError, + ); + expect(pendingConfirmationRepo.claim).not.toHaveBeenCalled(); + expect(eventBus.publish).not.toHaveBeenCalled(); + }); + + it('hands free text back as the first message and posts no follow-up', async () => { + const { service, memoryService } = setup(); + + const card = await service.answerCard(user.id, CARD_REQUEST_ID, freeTextAnswer); + + expect(card).toEqual({ + threadId: THREAD_ID, + firstMessage: expect.stringContaining('typed "i just want to import a csv"'), + }); + expect(memoryService.seedOpeningMessages).not.toHaveBeenCalled(); + }); + + it('posts the follow-up as a finished run when the card holds no free text', async () => { + const { service, memoryService } = setup(); + + const card = await service.answerCard(user.id, CARD_REQUEST_ID, { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: ['Gmail', 'Slack'] }], + }); + + expect(card).toEqual({ threadId: THREAD_ID, runId: expect.any(String) }); + expect(memoryService.seedOpeningMessages).toHaveBeenCalledWith( + THREAD_ID, + user.id, + 'Got it! Tell me a little about how you use Gmail and Slack.', + expect.stringContaining('<onboarding-answer>'), + ); + }); +}); + +describe('startsOnboardingFirstTurn', () => { + it.each<[boolean, string, string, InstanceAiConfirmRequest]>([ + [true, 'free text on the onboarding card', CARD_REQUEST_ID, freeTextAnswer], + [ + false, + 'blank free text on the onboarding card', + CARD_REQUEST_ID, + { + kind: 'questions', + answers: [{ questionId: 'apps', selectedOptions: ['Gmail'], customText: ' ' }], + }, + ], + [false, 'an answer of another kind on the onboarding card', CARD_REQUEST_ID, approval], + [false, 'free text on another card', 'req-1', freeTextAnswer], + ])('returns %s for %s', (expected, _label, requestId, request) => { + expect(startsOnboardingFirstTurn(requestId, request)).toBe(expected); + }); +}); + +describe('ONBOARDING_OPENING apps step', () => { + const team = ONBOARDING_OPENING.questions.find((question) => question.id === 'team'); + const apps = ONBOARDING_OPENING.questions.find((question) => question.id === 'apps'); + const universal = ['Google Sheets', 'Gmail', 'WhatsApp', 'Telegram']; + + it('lists 7 apps for an unknown team', () => { + expect(apps?.options).toHaveLength(7); + }); + + it('lists 3 team tools and then the 4 universal apps for every team', () => { + const byTeam = apps?.optionsByAnswer?.options ?? {}; + expect(Object.keys(byTeam).sort()).toEqual([...(team?.options ?? [])].sort()); + for (const options of Object.values(byTeam)) { + expect(options).toHaveLength(7); + expect(new Set(options).size).toBe(7); + expect(options.slice(3)).toEqual(universal); + } + }); +}); diff --git a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts index 5be8c9dcf70..59769a445c2 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts @@ -16,6 +16,7 @@ import { GlobalConfig } from '@n8n/config'; import type { InstanceAiConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; import { buildAgentTreeFromEvents, createSubAgentResourceIdPrefix, @@ -31,6 +32,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { InstanceAiCheckpoint } from './entities/instance-ai-checkpoint.entity'; import { DurableLogMetrics } from './event-bus/durable-log-metrics'; +import { AUTO_FOLLOW_UP_MESSAGE } from './internal-messages'; import { collectConfirmationRequestIds, markExpiredConfirmations, @@ -347,11 +349,13 @@ export class InstanceAiMemoryService { }; } + /** `title` names a host-opened thread from the start: the header never shows the first user message. */ async ensureThread( userId: string, threadId: string, projectId: string, launchMetadata: InstanceAiThreadLaunchMetadata, + title = '', ): Promise<InstanceAiEnsureThreadResponse> { const existing = await this.agentMemory.getThread(threadId); if (existing) { @@ -369,7 +373,7 @@ export class InstanceAiMemoryService { { id: threadId, resourceId: userId, - title: '', + title, metadata: { source: launchMetadata.source, origin: launchMetadata.origin, @@ -385,6 +389,46 @@ export class InstanceAiMemoryService { }; } + /** + * Store an assistant greeting before any user turn (onboarding). The model API + * needs a user message first, so a hidden auto-follow-up turn precedes the + * greeting; the message parser drops that turn from the UI. `hiddenUserText` + * replaces the auto-follow-up text when the hidden turn carries context for + * the model (the onboarding answers). Returns the id of that hidden turn. + */ + async seedOpeningMessages( + threadId: string, + userId: string, + greeting: string, + hiddenUserText: string = AUTO_FOLLOW_UP_MESSAGE, + ): Promise<{ userMessageId: string }> { + // Both stamps stay in the past: event rows written right after this must + // not sort before the greeting, or the fold shows the greeting twice. + const now = Date.now(); + const userMessageId = randomUUID(); + await this.agentMemory.saveMessages({ + threadId, + resourceId: userId, + messages: [ + { + id: userMessageId, + createdAt: new Date(now - 1), + type: 'llm', + role: 'user', + content: [{ type: 'text', text: hiddenUserText }], + }, + { + id: randomUUID(), + createdAt: new Date(now), + type: 'llm', + role: 'assistant', + content: [{ type: 'text', text: greeting }], + }, + ], + }); + return { userMessageId }; + } + /** Eval-only: seed a thread with a native message log (id/role/content/createdAt * preserved verbatim) so the runtime continues as if it really happened. The * thread must exist; referenced artifacts are recreated by the caller. */ 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 5162c06b945..df6cf1fd53c 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 @@ -494,6 +494,8 @@ export class InstanceAiAdapterService { * Falsy → `list` keeps the pre-feature shape: no folder fields, no * folder attribution. */ folderExplorationEnabled?: boolean; + /** True while the thread runs the onboarding flow. Gates the `leave-onboarding` tool. */ + onboardingThread?: boolean; credentialDescriptionsEnabled?: boolean; /** Saved AI preferences gate (via `resolveExperimentGates`). Falsy → no * `save_user_preference` tool. */ @@ -518,6 +520,7 @@ export class InstanceAiAdapterService { instanceContextEnabled, conversationHistory, folderExplorationEnabled, + onboardingThread, credentialDescriptionsEnabled, aiPreferencesEnabled, modelId, @@ -538,6 +541,7 @@ export class InstanceAiAdapterService { userId: user.id, projectId, ...(folderExplorationEnabled ? { folderExplorationEnabled: true } : {}), + ...(onboardingThread ? { onboardingThread: true } : {}), ...(credentialDescriptionsEnabled ? { credentialDescriptionsEnabled: true } : {}), modelId, workflowService: this.createWorkflowAdapter(user, threadId, projectId, { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 4b7ace56d01..da7d3692ff7 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -83,6 +83,7 @@ import { InstanceAiPreferenceCardService } from './instance-ai-preference-card.s import { InstanceAiSettingsService } from './instance-ai-settings.service'; import { InstanceAiVerificationService } from './instance-ai-verification.service'; import { InstanceAiService } from './instance-ai.service'; +import { InstanceAiOnboardingService, startsOnboardingFirstTurn } from './onboarding'; import { CredentialsService } from '@/credentials/credentials.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -107,6 +108,7 @@ export class InstanceAiController { private readonly gatewayService: InstanceAiGatewayService, private readonly browserSessionService: InstanceAiBrowserSessionService, private readonly memoryService: InstanceAiMemoryService, + private readonly onboarding: InstanceAiOnboardingService, private readonly pendingAgentService: InstanceAiPendingAgentService, private readonly settingsService: InstanceAiSettingsService, private readonly modelCatalogService: InstanceAiModelCatalogService, @@ -590,6 +592,22 @@ export class InstanceAiController { throw new BadRequestError(parseResult.error.errors[0].message); } + // The host-seeded onboarding card has no run to resume: settle it and post the follow-up + // question as a finished synthetic run. The user's next chat message starts the first turn. + // Free text in the card starts that turn now, with the answers as the message, so the model + // check of `chat` applies; it runs before the card is consumed. + if (startsOnboardingFirstTurn(requestId, parseResult.data)) { + await this.requireModelConfigured(); + } + const card = await this.onboarding.answerCard(req.user.id, requestId, parseResult.data); + if (card) { + const runId = + 'firstMessage' in card + ? this.instanceAiService.startRun(req.user, card.threadId, card.firstMessage) + : card.runId; + return { ok: true, runId }; + } + const resolved = await this.instanceAiService.resolveConfirmation( req.user.id, requestId, @@ -913,14 +931,21 @@ export class InstanceAiController { origin: payload.origin ?? ('internal' as const), sourceContext: payload.sourceContext, }; - try { - return await this.memoryService.ensureThread( - req.user.id, - requestedThreadId, - payload.projectId, - launchMetadata, - ); + // An onboarding thread opens with the greeting and the first question card in place. + return payload.source === 'onboarding' + ? await this.onboarding.ensureThread( + req.user, + requestedThreadId, + payload.projectId, + launchMetadata, + ) + : await this.memoryService.ensureThread( + req.user.id, + requestedThreadId, + payload.projectId, + launchMetadata, + ); } catch (error) { this.instanceAiErrorReporter.report(error, { component: 'instance-ai-ensure-thread', diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 9ad110cf78e..b0a1848223d 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -212,6 +212,7 @@ import { asStoredThreadContextSection, cleanStoredUserMessage, buildCurrentDateTimeBlock, + buildOnboardingSkillBlock, buildPastConversationsBlock, buildProjectContextBlock, buildThreadArtifactsBlock, @@ -222,6 +223,8 @@ import { WORKFLOW_SETUP_STATE_CLOSE_TAG, WORKFLOW_SETUP_STATE_OPEN_TAG, } from './internal-messages'; +import { loadOnboardingSkill } from './onboarding'; +import { ONBOARDING_OPENING } from './onboarding-opening'; import { INSTANCE_AI_RUN_TIMEOUT_REASON, InstanceAiLivenessService } from './liveness'; import { InstanceAiMcpRegistryService } from './mcp'; import { @@ -2525,6 +2528,11 @@ export class InstanceAiService { }); const buildMode = selectedPrompt.profile.mode; this.runState.setBuildMode(threadId, buildMode); + // The frontend writes the exit to thread metadata when the agent calls `leave-onboarding` or + // starts a build, so a thread that left gets the tool no more. + const thread = await memory.getThread(threadId); + const onboardingThread = + thread?.metadata?.source === 'onboarding' && !thread.metadata.onboardingLeft; const context = this.adapterService.createContext(user, { searchProxyConfig, pushRef, @@ -2540,6 +2548,7 @@ export class InstanceAiService { instanceContextEnabled, conversationHistory, folderExplorationEnabled, + onboardingThread, credentialDescriptionsEnabled, aiPreferencesEnabled, modelId, @@ -4069,11 +4078,17 @@ export class InstanceAiService { // the LLM title pass doesn't summarize the internal context block. const thread = await memory.getThread(threadId); // The heuristic title lands on the opening turn, so "no title yet" marks it. - const isOpeningTurn = Boolean(thread && !thread.title); + // An onboarding thread is titled at creation and opens with a seeded greeting. Its first + // user turn answers it, and is the turn that marks the title final below. + const unopenedOnboarding = + thread?.metadata?.source === 'onboarding' && !thread.metadata.titleRefined; + const isOpeningTurn = Boolean(thread && (!thread.title || unopenedOnboarding)); + const onboardingSkill = unopenedOnboarding ? await loadOnboardingSkill() : undefined; if (isOpeningTurn) { - const handoffTitle = - contextAttachments.find(isNamedResourceAttachment)?.name ?? agentPreviewTitleFallback; + const handoffTitle = unopenedOnboarding + ? ONBOARDING_OPENING.title + : (contextAttachments.find(isNamedResourceAttachment)?.name ?? agentPreviewTitleFallback); await patchThread(memory, { threadId, @@ -4185,6 +4200,9 @@ export class InstanceAiService { : undefined; const threadContextBlock = buildThreadContextBlock([ instanceContext.state === 'injected' ? instanceContext.block : '', + // The onboarding skill rides the opening turn, so it fires without a `load_skill` call + // and stays in the history for the later turns. + onboardingSkill ? buildOnboardingSkillBlock(onboardingSkill) : undefined, threadArtifactsBlock, projectSection ? buildProjectContextBlock(projectSection) : undefined, pastConversationsSection diff --git a/packages/cli/src/modules/instance-ai/internal-messages.ts b/packages/cli/src/modules/instance-ai/internal-messages.ts index 2fa0adeb5f2..961cd05dae1 100644 --- a/packages/cli/src/modules/instance-ai/internal-messages.ts +++ b/packages/cli/src/modules/instance-ai/internal-messages.ts @@ -127,6 +127,7 @@ const TRAILING_CONTEXT_BLOCKS = [ 'project-context', 'past-conversations', 'ai-preferences', + 'onboarding-answer', ].map(trailingBlockRegex); /** Strip each trailing block once, in whatever order they were composed. */ @@ -247,7 +248,7 @@ const PROMPT_TEXT_MAX_LENGTH = 128; * or start a new line. Angle brackets are escaped rather than dropped, so a * name that legitimately contains one still reads as itself. */ -export function sanitisePromptText(value: string): string { +export function sanitisePromptText(value: string, maxLength = PROMPT_TEXT_MAX_LENGTH): string { const printable = Array.from(value) .map((character) => { const code = character.codePointAt(0) ?? 0; @@ -260,13 +261,68 @@ export function sanitisePromptText(value: string): string { .replace(/>/g, '>') .replace(/\s+/g, ' ') .trim() - .slice(0, PROMPT_TEXT_MAX_LENGTH); + .slice(0, maxLength); } /** The fact, and only the fact. The rule that follows from it ("writes are locked to * this project", "check it before you build") lives in the system prompt, which is * CACHED — restating it here would pay for the same sentence in uncached tokens on * every turn of every conversation. Measured: the fact alone is enough. */ +/** + * The onboarding skill's SKILL.md body, one section of the opening turn's thread-context block, so + * the flow runs without a `load_skill` call. + */ +export function buildOnboardingSkillBlock(instructions: string): string { + return `<onboarding-skill>\nThis skill is loaded for this thread: follow it and do not call load_skill for it.\n${instructions}\n</onboarding-skill>`; +} + +/** One opening-card answer as the agent gets it: the question text plus what the user chose or typed. */ +export interface OnboardingAnswer { + question: string; + selectedOptions: string[]; + customText?: string; +} + +/** + * The answers to an onboarding thread's opening card, one line per question, so a question added + * to the opening file reaches the agent without a code change. The card is not in the LLM + * history (it lives in the event log), so the answers ride a hidden user turn; the parser drops + * that turn from the UI. + */ +export function buildOnboardingAnswerMessage(answers: OnboardingAnswer[]): string { + // Free text skips the host follow-up: only the agent can tell a tool name from a task or a + // wish to stop, so it gets the answers as the first turn and hears that no question is open. + const freeText = answers.some((answer) => answer.customText?.trim()); + return [ + AUTO_FOLLOW_UP_MESSAGE, + '', + '<onboarding-answer>', + 'The user answered the opening questions:', + ...answers.map((answer) => `- ${answer.question} ${formatOnboardingAnswer(answer)}`), + 'These answers are final: use them as they are and do not ask these questions again.', + ...(freeText + ? [ + 'The typed answer is free text the user wrote into the card in place of a pick. Nobody has asked them about a task yet.', + ] + : []), + '</onboarding-answer>', + ].join('\n'); +} + +/** + * The selected options, then the free text as `typed "…"`; `(not answered)` when the card had + * neither. Both come from the client, so they are escaped like every other value the host puts in + * a prompt block; the free text is the user's whole first message, so it keeps its length. + */ +function formatOnboardingAnswer({ selectedOptions, customText }: OnboardingAnswer): string { + const text = customText && sanitisePromptText(customText, Infinity); + const values = [ + ...selectedOptions.map((option) => sanitisePromptText(option)), + ...(text ? [`typed "${text}"`] : []), + ]; + return values.length > 0 ? values.join(', ') : '(not answered)'; +} + export function getProjectContextSection(project: { name: string; type: string }): string { return `This conversation is scoped to the project "${sanitisePromptText(project.name)}" (${project.type}).`; } diff --git a/packages/cli/src/modules/instance-ai/onboarding-opening.ts b/packages/cli/src/modules/instance-ai/onboarding-opening.ts new file mode 100644 index 00000000000..671efb5e851 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/onboarding-opening.ts @@ -0,0 +1,77 @@ +import type { InstanceAiQuestion } from '@n8n/api-types'; + +/** The opening of every onboarding thread: the copy shown before the agent's first turn. */ +interface OnboardingOpening { + /** Thread title from creation, and the heading of the opening card. */ + title: string; + /** + * First assistant message. Markdown: a blank line starts a paragraph, and the chat shows the + * paragraphs one after the other before the card. `{{firstName}}` becomes the user's first + * name, or "there". + */ + greeting: string; + /** + * Steps of the ONE `ask-user` card shown before the agent's first turn, in this order. A + * `required` step has no Skip button; the card's own "Something else" free text is the way out + * of a `single` or `multi` step. The host skips a step the signup survey already answered; that + * answer still reaches the agent. The answers reach the agent in an `<onboarding-answer>` block, + * one line per question, so a new question needs no code change. + */ + questions: InstanceAiQuestion[]; + /** + * Plain assistant text the host posts when the card is answered, with no model turn; the user + * types the task in the chat. Free text in an answer skips it: the answers start the agent's + * first turn instead. `{{apps}}` becomes the picked apps: one or two names, or "Gmail, Slack, + * and your other tools"; none picked: "your tools". + */ + followUp: string; +} + +// biome-ignore format: one app list per line reads as the table it is +export const ONBOARDING_OPENING: OnboardingOpening = { + title: 'Welcome to n8n', + greeting: [ + "Hi {{firstName}}, I'm your Assistant. Think of me as your n8n expert.", + "I'll ask 2 questions to finish your setup and suggest automations based on real workflows.", + ].join('\n\n'), + followUp: 'Got it! Tell me a little about how you use {{apps}}.', + questions: [ + // The n8n Cloud signup form's team labels, so the answers stay comparable with the Cloud + // metadata (`what_team_are_you_on`). The card hides options that start with "other"; its + // "Something else" field stands in for Other. A survey team answers this step, and the card + // then starts at the apps step with that team's list. + { + id: 'team', + question: 'What team are you on?', + type: 'single', + required: true, + options: ['Executive/Owner', 'Support', 'Product & Design', 'Sales', 'IT', 'Engineering', 'Marketing'], + }, + // Per team, the 3 tools most typical of that team in the Cloud onboarding data (tools with an + // n8n node only), then the 4 apps that lead every team: Google Sheets, Gmail, WhatsApp and + // Telegram. The team tools come first so the personalization shows. `options` shows when no + // team is known (a free-text team, the survey's "Other"): the 7 apps users without a team add + // most. The card's "Something else" row is the eighth option. `{{team}}` becomes the team from + // the signup survey ("in your Sales work"). Without one the words close up ("in your work"): + // no survey team, "Other", or the team step in the card. + { + id: 'apps', + question: 'Which apps do you use most in your {{team}} work?', + type: 'multi', + required: true, + options: ['Google Sheets', 'Gmail', 'Telegram', 'Google Drive', 'WhatsApp', 'Slack', 'Airtable'], + optionsByAnswer: { + questionId: 'team', + options: { + 'Executive/Owner': ['Google Calendar', 'LinkedIn', 'Slack', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + Support: ['Zendesk', 'Intercom', 'Slack', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + 'Product & Design': ['Linear', 'Notion', 'Supabase', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + Sales: ['HubSpot', 'Salesforce', 'LinkedIn', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + IT: ['Slack', 'Microsoft Teams', 'Jira', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + Engineering: ['GitHub', 'Jira', 'Linear', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + Marketing: ['Facebook', 'LinkedIn', 'YouTube', 'Google Sheets', 'Gmail', 'WhatsApp', 'Telegram'], + }, + }, + }, + ], +}; diff --git a/packages/cli/src/modules/instance-ai/onboarding.ts b/packages/cli/src/modules/instance-ai/onboarding.ts new file mode 100644 index 00000000000..2a66f962295 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/onboarding.ts @@ -0,0 +1,407 @@ +import type { + InstanceAiConfirmRequest, + InstanceAiEnsureThreadResponse, + InstanceAiEvent, + InstanceAiQuestion, +} from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { + ASK_USER_TOOL_ID, + loadInstanceAiRuntimeSkillSource, + orchestratorAgentId, +} from '@n8n/instance-ai'; +import { redactTelemetryText, TELEMETRY_EVENT } from '@n8n/telemetry'; +import { UnexpectedError } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { Telemetry } from '@/telemetry'; + +import { DurableEventLog } from './event-bus/durable-event-log'; +import { InProcessEventBus } from './event-bus/in-process-event-bus'; +import { + InstanceAiMemoryService, + type InstanceAiThreadLaunchMetadata, +} from './instance-ai-memory.service'; +import { buildOnboardingAnswerMessage } from './internal-messages'; +import { ONBOARDING_OPENING } from './onboarding-opening'; +import { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository'; + +/** Folder id under `@n8n/instance-ai/skills` of the skill preloaded on onboarding threads. */ +export const ONBOARDING_SKILL_ID = 'suggest-automations'; + +/** + * Marks the host-seeded card's confirmation request so the confirm endpoint can tell it from a + * card that has a run behind it. Fits the 36-char `requestId` column together with a nanoid. + */ +const CARD_REQUEST_ID_PREFIX = 'onboarding-'; +/** Start of the `${N8N_*}` placeholders the sandbox materializer substitutes; split so the lint rule for interpolation does not fire. */ +const PRELOAD_FORBIDDEN_PLACEHOLDER_PREFIX = '$' + '{N8N_'; +type Question = InstanceAiQuestion; +type GivenAnswer = Extract<InstanceAiConfirmRequest, { kind: 'questions' }>['answers'][number]; +/** One answer in the `ask-user` result shape: the wire answer plus the question text. */ +type Answer = GivenAnswer & { question: string }; + +/** + * The n8n Cloud signup survey answers the launch context may carry under `survey`, keyed like + * the cloud stores them in the account `information` (`GET /rest/cloud/proxy/user/me`). Other + * keys are dropped. The card skips a step the survey answers; the answer still reaches the agent. + */ +const surveySchema = z.object({ what_team_are_you_on: z.string().max(100).optional() }); +type Survey = z.infer<typeof surveySchema>; +/** Where the survey came from: the n8n Cloud account, or the `?team=` query of a test run. */ +const surveySourceSchema = z.enum(['cloud', 'url']); +type SurveySource = z.infer<typeof surveySourceSchema> | null; +const launchContextSchema = z.object({ + survey: surveySchema.optional(), + surveySource: surveySourceSchema.optional(), +}); +/** Card question id -> the survey key that answers it. */ +// ponytail: one pair; move it into the opening when a second survey-backed question shows up. +const SURVEY_KEY_BY_QUESTION: Partial<Record<string, keyof Survey>> = { + team: 'what_team_are_you_on', +}; + +/** True when the answer holds free text: the card answer then starts the first model turn. */ +function hasFreeText(request: InstanceAiConfirmRequest): boolean { + return ( + request.kind === 'questions' && request.answers.some((answer) => answer.customText?.trim()) + ); +} + +/** True when `requestId` is the onboarding card and answering it with `request` starts a model run. */ +export function startsOnboardingFirstTurn( + requestId: string, + request: InstanceAiConfirmRequest, +): boolean { + return requestId.startsWith(CARD_REQUEST_ID_PREFIX) && hasFreeText(request); +} + +function surveyOf(sourceContext: unknown): { survey: Survey; surveySource: SurveySource } { + const parsed = launchContextSchema.safeParse(sourceContext ?? {}); + if (!parsed.success) throw new BadRequestError('Invalid onboarding survey in sourceContext'); + return { survey: parsed.data.survey ?? {}, surveySource: parsed.data.surveySource ?? null }; +} + +/** + * Split the opening into the steps the card shows and the answers the survey already gave. A + * shown step that picks its options from a survey-answered step gets them resolved here: the + * card resolves `optionsByAnswer` only from its own steps. `{{team}}` in a question text becomes + * the survey's team. A value outside the step's options (the survey's "Other") names no team: + * texts drop it and dependents keep their fallback options. + */ +function applySurvey(questions: Question[], survey: Survey) { + const answered = new Map<string, string>(); + for (const question of questions) { + const key = SURVEY_KEY_BY_QUESTION[question.id]; + const value = key && survey[key]?.trim(); + if (value) answered.set(question.id, value); + } + const known = (id: string) => { + const value = answered.get(id); + return value && questions.find((question) => question.id === id)?.options?.includes(value) + ? value + : ''; + }; + // Without a team the words around the placeholder close up: "in your work". + const fill = (text: string) => + text.replace(/\{\{(\w+)\}\}/g, (_, id: string) => known(id)).replace(/ {2,}/g, ' '); + const filled = questions.map((question) => ({ ...question, question: fill(question.question) })); + const shown = filled.flatMap((question) => { + if (answered.has(question.id)) return []; + const dependency = question.optionsByAnswer; + if (!dependency || !answered.has(dependency.questionId)) return [question]; + return [ + { + ...question, + options: dependency.options[known(dependency.questionId)] ?? question.options, + optionsByAnswer: undefined, + }, + ]; + }); + return { questions: filled, shown, answered }; +} + +/** "Gmail", "Gmail and Slack", or "Gmail, Slack, and your other tools" for the follow-up text. */ +function mentionApps(apps: string[]): string { + if (apps.length === 0) return 'your tools'; + if (apps.length <= 2) return apps.join(' and '); + return `${apps[0]}, ${apps[1]}, and your other tools`; +} + +/** + * Body of the preloaded skill, sent with the opening turn as is. The sandbox materializer + * substitutes `${N8N_*}` placeholders only in the skills it writes to the workspace, so a + * placeholder here would reach the shell as an unset variable. + */ +export async function loadOnboardingSkill(): Promise<string> { + const skill = await loadInstanceAiRuntimeSkillSource().loadSkill(ONBOARDING_SKILL_ID); + if (!skill) throw new UnexpectedError(`Runtime skill "${ONBOARDING_SKILL_ID}" not found`); + if (skill.instructions.includes(PRELOAD_FORBIDDEN_PLACEHOLDER_PREFIX)) { + throw new UnexpectedError( + `Runtime skill "${ONBOARDING_SKILL_ID}" is preloaded and must not use \${N8N_*} placeholders`, + ); + } + return skill.instructions; +} + +/** + * The agent-first onboarding thread. The greeting and one question card are stored before the + * agent's first turn, the card as a finished synthetic run whose `ask-user` call still waits + * for an answer. The UI shows, answers and restores the card through the same HITL path as a + * live one. The answer settles the card and posts the follow-up question the same way, with no + * model turn; the task the user types next starts the first real turn. Free text in the card + * starts that turn at once instead, with the answers as the message. + */ +@Service() +export class InstanceAiOnboardingService { + constructor( + private readonly memoryService: InstanceAiMemoryService, + private readonly eventBus: InProcessEventBus, + private readonly eventLog: DurableEventLog, + private readonly pendingConfirmationRepo: InstanceAiPendingConfirmationRepository, + private readonly telemetry: Telemetry, + ) {} + + async ensureThread( + user: User, + threadId: string, + projectId: string, + launchMetadata: InstanceAiThreadLaunchMetadata, + ): Promise<InstanceAiEnsureThreadResponse> { + // Read the survey before the thread exists, so a bad survey creates nothing. + const { survey, surveySource } = surveyOf(launchMetadata.sourceContext); + const { shown } = applySurvey(ONBOARDING_OPENING.questions, survey); + const response = await this.memoryService.ensureThread( + user.id, + threadId, + projectId, + launchMetadata, + ONBOARDING_OPENING.title, + ); + if (!response.created) return response; + + const greeting = ONBOARDING_OPENING.greeting.replace( + '{{firstName}}', + user.firstName?.trim() || 'there', + ); + try { + // The LLM history needs the greeting as an assistant turn; the card lives in the event log. + const { userMessageId } = await this.memoryService.seedOpeningMessages( + threadId, + user.id, + greeting, + ); + await this.seedTurn({ + threadId, + userId: user.id, + messageId: userMessageId, + text: greeting, + card: { title: ONBOARDING_OPENING.title, questions: shown }, + }); + } catch (error) { + // A thread without its greeting and card would open empty and never get them: remove it, + // so the next visit creates a whole one. + await this.memoryService.deleteThread(threadId); + throw error; + } + const team = survey.what_team_are_you_on?.trim() || null; + this.telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_STARTED_AI_ASSISTANT_ONBOARDING, { + user_id: user.id, + thread_id: threadId, + team, + team_source: team ? surveySource : null, + }); + return response; + } + + /** + * Settle the host-seeded card with the user's answers, then post the follow-up question as a + * second finished synthetic run and return its id. Free text in an answer returns the answers + * as `firstMessage` instead, for the caller to start the first turn with: only the agent can + * tell a tool name from a task or a wish to stop. Returns `undefined` when `requestId` is not + * that card (the HITL path owns it then, and answers a consumed request with 404 like for any + * card). + */ + async answerCard( + userId: string, + requestId: string, + request: InstanceAiConfirmRequest, + ): Promise< + { threadId: string; runId: string } | { threadId: string; firstMessage: string } | undefined + > { + if (!requestId.startsWith(CARD_REQUEST_ID_PREFIX)) return undefined; + // Before the claim: a refused request must leave the card open. + if (request.kind !== 'questions') { + throw new BadRequestError('The onboarding card takes answers of kind "questions"'); + } + const row = await this.pendingConfirmationRepo.claim(requestId, userId); + if (!row?.toolCallId) return undefined; + + const metadata = await this.memoryService.getThreadMetadata(userId, row.threadId); + const { survey, surveySource } = surveyOf(metadata?.sourceContext); + const { questions, shown, answered } = applySurvey(ONBOARDING_OPENING.questions, survey); + const given = request.answers; + // One answer per shown step in card order, with the question text like the `ask-user` tool adds. + const answerFor = (question: Question): Answer => ({ + ...(given.find((answer) => answer.questionId === question.id) ?? { + questionId: question.id, + selectedOptions: [], + skipped: true, + }), + question: question.question, + }); + const answers = shown.map(answerFor); + // Same result shape as the `ask-user` tool, so the UI and the agent read it the same way. + this.eventBus.publish(row.threadId, { + type: 'tool-result', + runId: row.runId, + agentId: orchestratorAgentId(row.runId), + payload: { toolCallId: row.toolCallId, result: { answered: true, answers } }, + }); + // Committed before the follow-up writes its rows, so the fold keeps the card under the + // greeting. + await this.eventLog.flush(row.threadId); + // A survey answer counts as a pick, so the funnel reads the same from both entry points. + const picked = (id: string) => { + const fromSurvey = answered.get(id); + if (fromSurvey) return [fromSurvey]; + return given.find((answer) => answer.questionId === id)?.selectedOptions ?? []; + }; + const team = picked('team')[0] ?? null; + // The survey answered the team step, or the card did, or the user skipped it. + const teamSource = answered.has('team') ? surveySource : team ? 'card' : null; + this.telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_ANSWERED_AI_ASSISTANT_ONBOARDING_CARD, { + user_id: userId, + thread_id: row.threadId, + team, + team_source: teamSource, + apps: picked('apps'), + custom_text: + redactTelemetryText( + given + .map((answer) => answer.customText?.trim()) + .filter(Boolean) + .join('\n'), + ) || null, + }); + // The card showed only what the survey left open; the agent gets every line, in opening order. + const lines = questions.map((question) => { + const fromSurvey = answered.get(question.id); + return fromSurvey + ? { question: question.question, selectedOptions: [fromSurvey] } + : answerFor(question); + }); + const answerMessage = buildOnboardingAnswerMessage(lines); + // Free text is the user's own words: a tool the list lacks, a task, or a wish to stop. The + // caller starts the first turn with the answers, and the host posts no follow-up. + if (hasFreeText(request)) { + return { threadId: row.threadId, firstMessage: answerMessage }; + } + // The LLM history reads the answers as the hidden user turn under the follow-up, so the + // task the user types next is a normal first turn. + const apps = answers.find((answer) => answer.questionId === 'apps')?.selectedOptions ?? []; + const followUp = ONBOARDING_OPENING.followUp.replace('{{apps}}', mentionApps(apps)); + const { userMessageId } = await this.memoryService.seedOpeningMessages( + row.threadId, + userId, + followUp, + answerMessage, + ); + const runId = await this.seedTurn({ + threadId: row.threadId, + userId, + messageId: userMessageId, + text: followUp, + }); + return { threadId: row.threadId, runId }; + } + + /** + * Store a host-written assistant turn as a finished synthetic run. With `card`, the run also + * holds an `ask-user` call that still waits for an answer, plus the pending row `answerCard` + * claims. Returns the run id. + */ + private async seedTurn(turn: { + threadId: string; + userId: string; + messageId: string; + text: string; + card?: { title: string; questions: Question[] }; + }): Promise<string> { + const { threadId, card } = turn; + const runId = randomUUID(); + // Own group like a real run: history dedupes assistant rows by group inside one visible turn, + // and the hidden `(continue)` rows open no turn, so a groupless greeting collapses into the + // first real turn after a reload. + const messageGroupId = `mg_${nanoid()}`; + const agentId = orchestratorAgentId(runId); + const events: InstanceAiEvent[] = [ + { + type: 'run-start', + runId, + agentId, + payload: { messageId: turn.messageId, messageGroupId }, + }, + // A delta, not a block: the log persists blocks but streams only deltas, and the + // follow-up must show while the confirm request is still in flight. + { type: 'text-delta', runId, agentId, responseId: nanoid(), payload: { text: turn.text } }, + ]; + const toolCallId = nanoid(); + const requestId = `${CARD_REQUEST_ID_PREFIX}${nanoid()}`; + if (card) { + const args = { questions: card.questions }; + events.push( + { + type: 'tool-call', + runId, + agentId, + payload: { toolCallId, toolName: ASK_USER_TOOL_ID, args }, + }, + { + type: 'confirmation-request', + runId, + agentId, + payload: { + requestId, + toolCallId, + toolName: ASK_USER_TOOL_ID, + args, + severity: 'info', + message: card.title, + inputType: 'questions', + questions: card.questions, + }, + }, + ); + } + // Finished, so the interrupted-run sweeper leaves it alone. A completed run keeps an + // unanswered card actionable (shared reducer, `run-finish`). + events.push({ type: 'run-finish', runId, agentId, payload: { status: 'completed' } }); + if (card) { + // Saved before the events go out: a client that answers the card as soon as it shows + // must find the row. No expiry: nothing could revive the card after a timeout. + // ponytail: kind 'inline' (no checkpoint) because the column CHECK allows only 'inline' and + // 'suspended'; a 'seeded' kind needs a migration. The prefix above is the real marker. + await this.pendingConfirmationRepo.save( + this.pendingConfirmationRepo.create({ + requestId, + threadId, + userId: turn.userId, + kind: 'inline', + runId, + messageGroupId, + toolCallId, + expiresAt: null, + }), + ); + } + for (const event of events) this.eventBus.publish(threadId, event); + // The client reads the messages right after the response; the fold reads committed rows. + await this.eventLog.flush(threadId); + return runId; + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 2c28e2deaeb..7e144ee80ea 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5519,6 +5519,7 @@ "instanceAi.onboarding.notSet": "Not set", "instanceAi.onboarding.disabled": "Disabled", "instanceAi.onboarding.foundOnServer": "Found in server configuration", + "instanceAi.onboardingGreeting.thinkingActivity": "Preparing your first question", "instanceAi.onboarding.recommended": "Recommended", "instanceAi.onboarding.existingConnection.label": "Connection", "instanceAi.onboarding.existingConnection.current": "Current connection", diff --git a/packages/frontend/editor-ui/src/app/layouts/InstanceAiLayout.vue b/packages/frontend/editor-ui/src/app/layouts/InstanceAiLayout.vue index 610107367be..b44457cede0 100644 --- a/packages/frontend/editor-ui/src/app/layouts/InstanceAiLayout.vue +++ b/packages/frontend/editor-ui/src/app/layouts/InstanceAiLayout.vue @@ -1,10 +1,18 @@ <script lang="ts" setup> -import { onMounted, onBeforeUnmount } from 'vue'; +import { computed, onMounted, onBeforeUnmount } from 'vue'; +import { useRoute } from 'vue-router'; import BaseLayout from './BaseLayout.vue'; import AppSidebar from '@/app/components/app/AppSidebar.vue'; import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; +import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store'; const pushConnectionStore = usePushConnectionStore(); +const route = useRoute(); +const instanceAiStore = useInstanceAiStore(); +// An onboarding thread hides the sidebar until the user leaves the onboarding. +const showSidebar = computed( + () => !instanceAiStore.isOnboardingChromeHidden(String(route.params.threadId ?? '')), +); onMounted(() => { pushConnectionStore.pushConnect(); @@ -17,7 +25,7 @@ onBeforeUnmount(() => { <template> <BaseLayout> - <template #sidebar> + <template v-if="showSidebar" #sidebar> <AppSidebar /> </template> <RouterView /> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 997a79ffb52..817c818a83a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -187,6 +187,10 @@ const currentThreadTitle = computed<string | undefined>(() => ), ); +// An onboarding thread hides the header and the artifacts panel until the user leaves the +// onboarding. The header keeps its space, so the chat does not move to the top edge. +const isOnboardingChromeHidden = computed(() => store.isOnboardingChromeHidden(props.threadId)); + // The tab names the conversation, not the workflow previewed inside it — the // parent view claims the title so the embedded canvas can't overwrite this. const documentTitle = useDocumentTitle(); @@ -404,6 +408,7 @@ const canShowArtifactsPanel = computed( const showArtifactsPanel = computed( () => canShowArtifactsPanel.value && + !isOnboardingChromeHidden.value && !preview.isPreviewVisible.value && (isArtifactsPanelInLayout.value ? !isArtifactsPanelDismissedInLayout.value @@ -750,7 +755,10 @@ function handleNewThreadClick() { :data-layout-animated="shouldAnimatePreviewLayout" data-test-id="instance-ai-builder-chat" > - <div :class="$style.builderChatHeader" data-test-id="instance-ai-builder-chat-header"> + <div + :class="[$style.builderChatHeader, { [$style.chromeHidden]: isOnboardingChromeHidden }]" + data-test-id="instance-ai-builder-chat-header" + > <InstanceAiViewHeader :show-thread-history-label="!currentThreadTitle"> <template #title> <N8nHeading @@ -1040,6 +1048,10 @@ function handleNewThreadClick() { flex-shrink: 0; } +.chromeHidden { + visibility: hidden; +} + .chatArea { flex: 1; display: flex; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiQuestions.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiQuestions.test.ts index fb7a9291a85..38230317482 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiQuestions.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiQuestions.test.ts @@ -125,6 +125,28 @@ describe('InstanceAiQuestions', () => { ]); }); + it('shows the second line of an option as its description and submits the whole option', async () => { + vi.useFakeTimers(); + const option = 'Capture new leads\nMove new enquiries into your sales process.'; + const { emitted, getByText } = render([ + { ...singleQuestion, options: [option, 'Show me other ideas'] }, + ]); + + expect(getByText('Move new enquiries into your sales process.')).toBeTruthy(); + await fireEvent.click(getByText('Capture new leads')); + vi.advanceTimersByTime(250); + + expect(emitted().submit).toEqual([[[expect.objectContaining({ selectedOptions: [option] })]]]); + }); + + it('shows the freeTextLabel of a question as the free-text placeholder', () => { + const { getByPlaceholderText } = render([ + { ...singleQuestion, freeTextLabel: 'Share more context' }, + ]); + + expect(getByPlaceholderText('Share more context')).toBeTruthy(); + }); + it('keeps Submit disabled while an option is only highlighted, not selected', async () => { const { container, getByTestId } = render([singleQuestion]); 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 e6f0f0c66dd..ac7a447bb1d 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 @@ -425,6 +425,20 @@ describe('buildTimelineBlocks', () => { expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(3); }); + test('the reply before a leave-onboarding call stays user-facing and the call is hidden', () => { + const blocks = blocksOf( + [ + reasoning('r1'), + text('Explore the app and come back with a task.', 'r1'), + toolEntry('tc-leave', 'r1'), + ], + [makeToolCall({ toolCallId: 'tc-leave', toolName: 'leave-onboarding' })], + ); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(1); + }); + test('text before a tool call that suspended on a setup card stays user-facing', () => { const setupCard = makeToolCall({ toolCallId: 'tc-setup', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts index 9abfd2047a1..f16bd3da1d8 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts @@ -14,10 +14,13 @@ import { type InstanceAiThreadHistoryResponse, type InstanceAiThreadSummary, } from '@n8n/api-types'; +import { useTelemetry } from '@n8n/composables/useTelemetry'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: vi.fn().mockReturnValue({ restApiContext: { baseUrl: 'http://localhost:5678/api' }, + instanceId: 'instance-1', }), })); @@ -460,3 +463,38 @@ describe('useInstanceAiStore - credits', () => { }); }); }); + +describe('useInstanceAiStore - onboarding exit', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('leaveOnboarding keeps the exit in the thread metadata and tracks the end once', () => { + const store = useInstanceAiStore(); + store.threads = [ + { + id: 'thread-1', + title: 'Onboarding', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + metadata: { source: 'onboarding', origin: 'external' }, + }, + ]; + + store.leaveOnboarding('thread-1', 'left', 'stop'); + // A second exit (the failed run after the leave call) is a no-op. + store.leaveOnboarding('thread-1', 'run_failed'); + + expect(store.threads[0].metadata).toMatchObject({ onboardingLeft: true }); + expect(store.isOnboardingChromeHidden('thread-1')).toBe(false); + const { track } = useTelemetry(); + expect(track).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.INSTANCE_AI.AI_ASSISTANT_ONBOARDING_ENDED, { + thread_id: 'thread-1', + instance_id: 'instance-1', + outcome: 'left', + leave_reason: 'stop', + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts index 5df720572ab..2ada1517557 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts @@ -3674,3 +3674,53 @@ describe('createThreadRuntime - requestPlanChanges', () => { expect(runtime.resolvedConfirmationIds.has('req-plan')).toBe(false); }); }); + +describe('createThreadRuntime - onboarding exit', () => { + const hooks = { + onTitleUpdated: vi.fn(), + onRunFinish: vi.fn(), + onOnboardingLeft: vi.fn(), + } satisfies Parameters<typeof createThreadRuntime>[1]; + let runtime: ThreadRuntime; + + const sse = (event: Record<string, unknown>) => + capturedOnMessage!(makeSSEEvent({ runId: 'run-1', agentId: 'agent-root', ...event })); + + beforeEach(async () => { + setupRuntimePinia(); + vi.clearAllMocks(); + capturedOnMessage = null; + runtime = createThreadRuntime('thread-onboarding', hooks); + runtime.connectSSE(); + await vi.waitFor(() => { + expect(capturedOnMessage).not.toBeNull(); + }); + sse(validRunStartEvent('run-1', 'agent-root')); + }); + + afterEach(() => { + runtime.closeSSE(); + }); + + test('a leave-onboarding call ends the onboarding with the reason the agent gave', () => { + sse({ type: 'tool-call', payload: { toolCallId: 'tc-1', toolName: 'search', args: {} } }); + expect(hooks.onOnboardingLeft).not.toHaveBeenCalled(); + + sse({ + type: 'tool-call', + payload: { toolCallId: 'tc-2', toolName: 'leave-onboarding', args: { reason: 'stop' } }, + }); + expect(hooks.onOnboardingLeft).toHaveBeenCalledTimes(1); + expect(hooks.onOnboardingLeft).toHaveBeenCalledWith('thread-onboarding', 'left', 'stop'); + }); + + test('a failed run ends the onboarding; a completed one does not', () => { + sse({ type: 'run-finish', payload: { status: 'completed' } }); + expect(hooks.onOnboardingLeft).not.toHaveBeenCalled(); + + sse(validRunStartEvent('run-2', 'agent-root')); + sse({ type: 'run-finish', runId: 'run-2', payload: { status: 'error' } }); + expect(hooks.onOnboardingLeft).toHaveBeenCalledTimes(1); + expect(hooks.onOnboardingLeft).toHaveBeenCalledWith('thread-onboarding', 'run_failed'); + }); +}); 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 77d0abbb935..4611fa1b332 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 @@ -6,8 +6,10 @@ import type { import { firstNonBlank, isActiveBuilderAgent, isBuilderAgent } from './builderAgents'; import { isPreferenceWriteOutcome, SAVE_USER_PREFERENCE_TOOL_NAME } from './preferenceCard.utils'; -/** Tool calls that are internal bookkeeping and should not be shown to the user. */ -export const HIDDEN_TOOLS = new Set(['updateWorkingMemory']); +/** Tool calls that are internal bookkeeping and should not be shown to the user. + * `leave-onboarding` ends the turn right after the model's reply: hiding it keeps + * that reply a user-facing final message instead of narration before a tool call. */ +export const HIDDEN_TOOLS = new Set(['updateWorkingMemory', 'leave-onboarding']); /** Render hints whose tool calls produce no output in the timeline — they are * represented elsewhere (child agent sections, artifact cards). */ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnsweredQuestions.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnsweredQuestions.vue index 8d44367126c..a3a47b4fda6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnsweredQuestions.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnsweredQuestions.vue @@ -33,7 +33,8 @@ function getAnswers(): DisplayAnswer[] { } const parts: string[] = []; if (Array.isArray(a.selectedOptions) && a.selectedOptions.length > 0) { - parts.push((a.selectedOptions as string[]).join(', ')); + // The first line of an option is its title; a description line stays in the card. + parts.push((a.selectedOptions as string[]).map((o) => o.split('\n')[0]).join(', ')); } if (typeof a.customText === 'string' && a.customText.trim()) { parts.push(a.customText.trim()); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue index 9f55100b69e..bac356efc48 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue @@ -74,6 +74,7 @@ function trackInputCompleted( thread_id: thread.id, input_thread_id: conf.inputThreadId ?? '', instance_id: rootStore.instanceId, + action_source: thread.resolveActionSource(), type: getConfirmationType(conf), provided_inputs: providedInputs, skipped_inputs: skippedInputs, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue index 3af1d180138..35757a41aad 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue @@ -78,6 +78,7 @@ import InstanceAiConfirmationPanel from './InstanceAiConfirmationPanel.vue'; import WorkflowBuilderUnavailableNotice from './WorkflowBuilderUnavailableNotice.vue'; import AgentSection from './AgentSection.vue'; import { collectActiveBuilderAgents, messageHasVisibleContent } from '../builderAgents'; +import AiThinkingBlock from '../../shared/components/AiThinkingBlock.vue'; import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue'; const props = defineProps<{ @@ -212,11 +213,80 @@ watch( // Show the input disclaimer only once the AI has produced a visible response. const hasAssistantResponse = computed(() => displayedMessages.some((m) => m.role === 'assistant')); +// ponytail: the host-seeded onboarding greeting shows line by line (CSS below), then the shared +// thinking block plays a thinking beat, then the apps card takes the input slot. Once per +// mount, so a reload replays it. `isStreaming` on the greeting copy hides the message actions. +/** The second line has risen at ~1.3 s, the card lands at ~2.3 s. */ +const GREETING_LINES_MS = 1400; +const GREETING_THINKING_MS = 940; +const greetingPhase = ref<'lines' | 'thinking' | null>(null); +let greetingShown = false; +let greetingTimer: ReturnType<typeof setTimeout> | null = null; +const onboardingGreeting = computed(() => { + const [first, second] = displayedMessages; + const alone = first?.role === 'assistant' && !second; + return alone && store.isOnboardingChromeHidden(thread.id) ? first : null; +}); +watch( + onboardingGreeting, + (greeting) => { + if (!greeting || greetingShown) return; + greetingShown = true; + greetingPhase.value = 'lines'; + greetingTimer = setTimeout(() => { + greetingPhase.value = 'thinking'; + greetingTimer = setTimeout(() => { + greetingPhase.value = null; + greetingTimer = null; + }, GREETING_THINKING_MS); + }, GREETING_LINES_MS); + }, + { immediate: true }, +); +// ponytail: the host answers the card without a model turn, so its follow-up would land in the +// same frame as the click. Hold it behind the thinking block for one beat, like the greeting. +const followUpHeld = ref(false); +let followUpTimer: ReturnType<typeof setTimeout> | null = null; +watch( + () => displayedMessages.length, + (length, previous) => { + if (previous !== 1 || length !== 2 || displayedMessages[1].role !== 'assistant') return; + if (!store.isOnboardingChromeHidden(thread.id)) return; + followUpHeld.value = true; + followUpTimer = setTimeout(() => { + followUpHeld.value = false; + followUpTimer = null; + }, GREETING_THINKING_MS); + }, +); +onUnmounted(() => { + if (greetingTimer) clearTimeout(greetingTimer); + if (followUpTimer) clearTimeout(followUpTimer); +}); +/** + * While the greeting plays: the greeting without its agent tree (the message renders its text + * from the tree when it has one). While the follow-up is held: the greeting only. The stored + * messages otherwise. + */ +const renderedMessages = computed(() => { + const greeting = onboardingGreeting.value; + if (greeting && greetingPhase.value !== null) { + return [{ ...greeting, agentTree: undefined, isStreaming: true }]; + } + return followUpHeld.value ? displayedMessages.slice(0, 1) : displayedMessages; +}); + // True when at least one pending confirmation should occupy the chat-input // slot (questions, generic approvals, or domain/web-search access). Drives -// the swap between the input and the floating confirmation panel. -const hasFloatingConfirmation = computed(() => - thread.pendingConfirmations.some(isPendingItemFloating), +// the swap between the input and the floating confirmation panel. The +// onboarding card waits until the greeting has played. +const hasFloatingConfirmation = computed( + () => greetingPhase.value === null && thread.pendingConfirmations.some(isPendingItemFloating), +); +// ponytail: an onboarding thread hides the chat input until its greeting has hydrated; the phase +// gate above takes over in the same tick, so the input never shows before the card. +const awaitingOnboardingGreeting = computed( + () => thread.hydrationStatus !== 'ready' && store.isOnboardingChromeHidden(thread.id), ); const composerContextChip = computed(() => { @@ -838,11 +908,29 @@ defineExpose({ </N8nChatMessage> <TransitionGroup name="message-slide"> <InstanceAiMessage - v-for="message in displayedMessages" + v-for="message in renderedMessages" :key="message.id" :message="message" + :class="{ [$style.greetingLines]: greetingPhase !== null }" /> </TransitionGroup> + <Transition name="message-slide"> + <N8nChatMessage + v-if="greetingPhase === 'thinking' || followUpHeld" + role="assistant" + data-test-id="instance-ai-onboarding-thinking" + > + <AiThinkingBlock + :segments="[]" + :active="true" + :activity-label=" + greetingPhase === 'thinking' + ? i18n.baseText('instanceAi.onboardingGreeting.thinkingActivity') + : undefined + " + /> + </N8nChatMessage> + </Transition> <!-- Builder sub-agents are extracted from their parent assistant messages and rendered here so they always sit at the bottom of the conversation. --> @@ -917,7 +1005,9 @@ defineExpose({ kind="floating" /> <InstanceAiInput - v-else + v-else-if=" + greetingPhase === null && !followUpHeld && !awaitingOnboardingGreeting + " ref="chatInputRef" key="chat-input" :is-streaming="thread.isStreaming" @@ -1107,6 +1197,33 @@ defineExpose({ .inputSwap { position: relative; } + +// The onboarding greeting's paragraphs rise one after the other while `greetingPhase` is set. +.greetingLines p { + animation: greeting-rise 300ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms both; +} + +.greetingLines p:nth-of-type(2) { + animation: greeting-rise 280ms ease-out 1000ms both; +} + +@keyframes greeting-rise { + from { + opacity: 0; + transform: translateY(9px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .greetingLines p { + animation: none; + } +} </style> <style lang="scss"> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiQuestions.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiQuestions.vue index 83fef1d4547..aef6e1b8d96 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiQuestions.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiQuestions.vue @@ -9,19 +9,21 @@ * Adapted from PlanQuestionsMessage.vue for the instance AI confirmation flow. */ import { ref, computed, watch, nextTick } from 'vue'; +import type { InstanceAiQuestion } from '@n8n/api-types'; import { N8nButton, N8nCheckbox, N8nIcon, N8nInput, N8nText } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import ConfirmationFooter from './ConfirmationFooter.vue'; const OTHER_SENTINEL = '__other__'; -export interface QuestionItem { - id: string; - question: string; - type: 'single' | 'multi' | 'text'; - options?: string[]; +/** An option of two lines is a title and a description; the card shows the second line under the first. */ +function splitOption(option: string) { + const [label, ...rest] = option.split('\n'); + return { label, description: rest.join(' ').trim() }; } +export type QuestionItem = InstanceAiQuestion; + export interface QuestionAnswer { questionId: string; question: string; @@ -54,11 +56,19 @@ const currentQuestion = computed(() => props.questions[currentIndex.value]); const isFirstQuestion = computed(() => currentIndex.value === 0); const isLastQuestion = computed(() => currentIndex.value === props.questions.length - 1); +// Options that follow an earlier answer track the option selected for that question. +const resolvedOptions = computed(() => { + const q = currentQuestion.value; + const dependency = q?.optionsByAnswer; + const picked = dependency + ? answers.value.get(dependency.questionId)?.selectedOptions[0] + : undefined; + return (picked === undefined ? undefined : dependency?.options[picked]) ?? q?.options ?? []; +}); + // Filter LLM-provided "Other" variants — we render our own "Something else" option const filteredOptions = computed(() => { - return (currentQuestion.value.options ?? []).filter( - (opt) => !opt.toLowerCase().trim().startsWith('other'), - ); + return resolvedOptions.value.filter((opt) => !opt.toLowerCase().trim().startsWith('other')); }); const currentAnswer = computed(() => { @@ -82,6 +92,7 @@ const hasValidAnswer = computed(() => { }); const showSkipButton = computed(() => { + if (currentQuestion.value?.required) return false; if (currentQuestion.value?.type === 'single' && hasCustomText.value) return false; return true; }); @@ -96,7 +107,7 @@ const isNextEnabled = computed(() => { const q = currentQuestion.value; if (!q) return false; // Blank text is a valid submission — it advances marked as skipped - if (q.type === 'text') return true; + if (q.type === 'text') return !q.required || hasCustomText.value; if (q.type === 'single') return hasValidAnswer.value; if (q.type === 'multi') { const answer = currentAnswer.value; @@ -125,6 +136,13 @@ watch( skipped: false, }); } + // A changed earlier answer can remove options this question already had selected. + const answer = q?.optionsByAnswer ? answers.value.get(q.id) : undefined; + if (answer) { + answer.selectedOptions = answer.selectedOptions.filter( + (option) => option === OTHER_SENTINEL || resolvedOptions.value.includes(option), + ); + } selectedIndex.value = null; highlightedIndex.value = currentQuestion.value?.type === 'text' ? -1 : 0; @@ -410,7 +428,12 @@ function onOptionMouseEnter(idx: number) { @mouseenter="onOptionMouseEnter(idx)" > <span :class="$style.numberBadge">{{ idx + 1 }}</span> - <span :class="$style.optionLabel">{{ option }}</span> + <span :class="$style.optionText"> + <span :class="$style.optionLabel">{{ splitOption(option).label }}</span> + <span v-if="splitOption(option).description" :class="$style.optionDescription"> + {{ splitOption(option).description }} + </span> + </span> <span :class="$style.arrowIndicator"> <N8nIcon :class="$style.arrowIcon" @@ -435,7 +458,10 @@ function onOptionMouseEnter(idx: number) { <N8nInput :model-value="currentAnswer.customText" :disabled="disabled" - :placeholder="i18n.baseText('aiAssistant.builder.planMode.questions.somethingElse')" + :placeholder=" + currentQuestion.freeTextLabel ?? + i18n.baseText('aiAssistant.builder.planMode.questions.somethingElse') + " size="small" :class="$style.somethingElseInput" data-test-id="instance-ai-something-else-input" @@ -462,7 +488,12 @@ function onOptionMouseEnter(idx: number) { :disabled="disabled" @update:model-value="(checked: boolean) => onMultiToggle(option, checked)" /> - <span :class="$style.optionLabel">{{ option }}</span> + <span :class="$style.optionText"> + <span :class="$style.optionLabel">{{ splitOption(option).label }}</span> + <span v-if="splitOption(option).description" :class="$style.optionDescription"> + {{ splitOption(option).description }} + </span> + </span> </label> <div @@ -485,7 +516,10 @@ function onOptionMouseEnter(idx: number) { <N8nInput :model-value="currentAnswer.customText" :disabled="disabled" - :placeholder="i18n.baseText('aiAssistant.builder.planMode.questions.somethingElse')" + :placeholder=" + currentQuestion.freeTextLabel ?? + i18n.baseText('aiAssistant.builder.planMode.questions.somethingElse') + " size="small" :class="$style.somethingElseInput" data-test-id="instance-ai-something-else-input" @@ -533,7 +567,7 @@ function onOptionMouseEnter(idx: number) { variant="ghost" size="medium" icon-only - :disabled="isLastQuestion" + :disabled="isLastQuestion || (!!currentQuestion.required && !hasValidAnswer)" data-test-id="instance-ai-questions-forward" aria-label="Next question" @click="goToNextWithoutAnswer" @@ -646,6 +680,19 @@ function onOptionMouseEnter(idx: number) { @include questionOptions.option-label; } +.optionText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.optionDescription { + color: var(--color--text--tint-1); + font-size: var(--font-size--sm); + font-weight: var(--font-weight--regular); + line-height: var(--line-height--lg); +} + .checkboxRow { @include questionOptions.checkbox-row; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts index 7151949718e..747d9cf4bfe 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts @@ -437,6 +437,39 @@ export async function provisionWorkflowThread( return threadId; } +/** The n8n Cloud signup survey answers the onboarding thread reads, keyed like the cloud stores them. */ +export interface InstanceAiOnboardingSurvey { + what_team_are_you_on?: string; +} + +/** The survey and where it came from, so telemetry can tell a `?team=` test run from a signup. */ +export type InstanceAiOnboardingLaunch = { + survey: InstanceAiOnboardingSurvey; + surveySource: 'cloud' | 'url'; +}; + +/** + * Provision the agent-first onboarding thread for the `/assistant?source=onboarding` router + * guard. The backend seeds the greeting and the first question card, so nothing is stashed for + * the destination view to send. Returns the thread id, or null if persistence failed. + */ +export async function provisionOnboardingThread( + projectId: string, + launch: InstanceAiOnboardingLaunch | undefined, +): Promise<string | null> { + const threadId = uuidv4(); + try { + await useInstanceAiStore().syncThread(threadId, projectId, { + source: 'onboarding', + origin: 'external', + sourceContext: launch, + }); + } catch { + return null; + } + return threadId; +} + /** * Mint a thread bound to a subject: the id, the target metadata (a pending * marker or a bound target), and — for the agent variant — the stashed diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts index e42da317ad6..7f53c1fe9b1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts @@ -11,7 +11,9 @@ import { } from 'vue'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useToast } from '@n8n/composables/useToast'; +import { i18n } from '@n8n/i18n'; import { useTelemetry } from '@n8n/composables/useTelemetry'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { UNLIMITED_CREDITS, type InstanceAiThreadHistoryResponse, @@ -36,7 +38,11 @@ import { updateThreadMetadata as updateThreadMetadataApi, } from './instanceAi.memory.api'; import { NEW_CONVERSATION_TITLE } from './constants'; -import { createThreadRuntime, type ThreadRuntime } from './instanceAi.threadRuntime'; +import { + createThreadRuntime, + type OnboardingExitOutcome, + type ThreadRuntime, +} from './instanceAi.threadRuntime'; import { mergeNodeSets } from './utils/buildNodesAttachment'; export type { PendingConfirmationItem, ThreadRuntime } from './instanceAi.threadRuntime'; @@ -89,6 +95,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { void loadThreads(); }, getThreadMetadata: (threadId) => threads.value.find((t) => t.id === threadId)?.metadata, + onOnboardingLeft: leaveOnboarding, } satisfies Parameters<typeof createThreadRuntime>[1]; function getOrCreateRuntime(threadId: string, projectId?: string): ThreadRuntime { @@ -389,9 +396,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { threadId: string, metadata: Record<string, unknown>, ): Promise<void> { - // Optimistic update - const thread = threads.value.find((t) => t.id === threadId); - if (thread) { + // Optimistic update, on every local copy + for (const thread of localThreadEntries(threadId)) { thread.metadata = { ...thread.metadata, ...metadata }; } @@ -432,6 +438,38 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { clearCanvasSelectionRequest.value++; } + // ponytail: the exits of this session, so a thread-list refresh that races the metadata write + // (`onRunFinish` reloads the list on the same event as the `run_failed` exit) cannot hide the + // chrome again. + const leftOnboardingThreadIds = new Set<string>(); + /** An onboarding thread hides the host chrome (chat header, sidebar, artifacts) until the user leaves it. */ + function isOnboardingChromeHidden(threadId: string): boolean { + return ( + !leftOnboardingThreadIds.has(threadId) && + localThreadEntries(threadId).some( + (t) => t.metadata?.source === 'onboarding' && !t.metadata.onboardingLeft, + ) + ); + } + /** The exit lives in thread metadata, so a reload keeps it. Idempotent. */ + function leaveOnboarding( + threadId: string, + outcome: OnboardingExitOutcome, + leaveReason?: string, + ): void { + if (!isOnboardingChromeHidden(threadId)) return; + leftOnboardingThreadIds.add(threadId); + updateThreadMetadata(threadId, { onboardingLeft: true }).catch((error: unknown) => { + toast.showError(error, i18n.baseText('generic.error')); + }); + telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.AI_ASSISTANT_ONBOARDING_ENDED, { + thread_id: threadId, + instance_id: rootStore.instanceId, + outcome, + leave_reason: leaveReason ?? null, + }); + } + return { // Instance-level state threads, @@ -473,6 +511,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { composerFocusRequest, requestComposerFocus, clearCanvasSelectionRequest, + isOnboardingChromeHidden, + leaveOnboarding, requestClearCanvasSelection, }; }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts index 5a03c1a3500..b44b24fda94 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts @@ -127,6 +127,14 @@ type SetupChatTelemetryContext = Pick< >; const MAX_DEBUG_EVENTS = 1000; +/** Tool calls that end the onboarding flow: the agent's explicit exit, or the start of a build. */ +/** How an onboarding thread ended; the telemetry value of each exit. */ +export type OnboardingExitOutcome = 'build' | 'left' | 'run_failed'; +/** Tool calls that end the onboarding flow, with the outcome each one reports. */ +const ONBOARDING_EXIT_OUTCOMES = new Map<string, OnboardingExitOutcome>([ + ['leave-onboarding', 'left'], + ['build-workflow', 'build'], +]); /** Mirrors the backend's per-thread event buffer cap (MAX_EVENTS_PER_THREAD × 2). */ const MAX_SEEN_EVENT_IDS = 1000; @@ -162,6 +170,12 @@ export interface ThreadRuntimeHooks { onTitleUpdated: (threadId: string, title: string) => void; /** A run finished — refresh the thread list to pick up server-generated titles. */ onRunFinish: () => void; + /** SSE delivered a tool call that ends the onboarding flow (`leave-onboarding` or `build-workflow`), or a failed run. */ + onOnboardingLeft?: ( + threadId: string, + outcome: OnboardingExitOutcome, + leaveReason?: string, + ) => void; /** Thread-list metadata, used to enrich historical artifacts. */ getThreadMetadata?: (threadId: string) => Record<string, unknown> | undefined; } @@ -899,6 +913,10 @@ export function createThreadRuntime( function rearmRunState(runId: string | null | undefined): void { if (!runId) return; + // A run the stream already finished (the host-seeded onboarding follow-up) must not + // come back as active: `run-finish` clears `activeRunId` only for the active run. + const groupId = groupIdByRunId.get(runId); + if (groupId && runStateByGroupId.get(groupId)?.status !== 'active') return; activeRunId.value = runId; markAssistantMessageStreaming(messages.value, runId); triggerRef(messages); @@ -1165,6 +1183,24 @@ export function createThreadRuntime( if (parsed.data.type === 'thread-title-updated') { hooks.onTitleUpdated(threadId, parsed.data.payload.title); } + // A failed or interrupted run (provider down, key rejected, crash, ...) ends the onboarding + // too, so the user gets the normal chrome back instead of a stuck flow. + if (parsed.data.type === 'tool-call') { + const outcome = ONBOARDING_EXIT_OUTCOMES.get(parsed.data.payload.toolName); + const reason = parsed.data.payload.args.reason; + if (outcome) { + hooks.onOnboardingLeft?.( + threadId, + outcome, + typeof reason === 'string' ? reason : undefined, + ); + } + } else if ( + parsed.data.type === 'run-finish' && + (parsed.data.payload.status === 'error' || parsed.data.payload.status === 'interrupted') + ) { + hooks.onOnboardingLeft?.(threadId, 'run_failed'); + } if (parsed.data.type === 'preferences-applied') { // Last write wins, like `latestTasks` and `latestSetupItems`: a thread runs one // turn at a time and each turn publishes one of these, so arrival order is turn @@ -1916,6 +1952,7 @@ export function createThreadRuntime( confirmAction, confirmResourceDecision, resolveConfirmation, + resolveActionSource, addAlwaysAllowKey, canAlwaysAllow, findToolCallByRequestId, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts index ee3862f8e75..e65d9f80b69 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts @@ -1,5 +1,6 @@ import { i18n } from '@n8n/i18n'; import { defineFrontendModule } from '@n8n/frontend-module-sdk'; +import type { LocationQuery } from 'vue-router'; import { VIEWS } from '@/app/constants'; import { INSTANCE_AI_MODALS } from './modals'; import { @@ -8,10 +9,13 @@ import { INSTANCE_AI_SETTINGS_VIEW, INSTANCE_AI_NEW_VIEW, INSTANCE_AI_THREADS_VIEW, + INSTANCE_AI_SOURCE_QUERY, } from './constants'; import { ensurePersonalProjectId, provisionLaunchedThread, + provisionOnboardingThread, + type InstanceAiOnboardingLaunch, } from './composables/useInstanceAiHandoff'; // Experiment cleanup: remove with openWorkflowInAssistant. import { launchWorkflowThread } from '@/experiments/openWorkflowInAssistant/launchWorkflowThread'; @@ -22,6 +26,7 @@ import { useInstanceAiReady, } from './composables/useInstanceAiAvailability'; import { useSettingsStore } from '@n8n/stores/settings.store'; +import { useCloudPlanStore } from '@n8n/stores/cloudPlan.store'; import { canManageInstanceAi } from './instanceAiPermissions'; /** @@ -45,6 +50,31 @@ function hasInstanceAiSettingsContent(): boolean { return isAssistantEnabled && useOpenWorkflowInAssistantStore().isTreatment; } +/** `?team=` on the onboarding handoff URL answers the survey's team question, on any deployment. */ +const ONBOARDING_TEAM_QUERY = 'team'; + +/** + * The team question of the n8n Cloud signup survey: from the URL when it carries one (a test + * run, or a handoff that passes it), otherwise from the cloud account. Only n8n Cloud has one; + * elsewhere the onboarding card asks the team itself. + */ +async function readSignupSurvey( + query: LocationQuery, +): Promise<InstanceAiOnboardingLaunch | undefined> { + const teamFromUrl = query[ONBOARDING_TEAM_QUERY]; + if (typeof teamFromUrl === 'string' && teamFromUrl) { + return { survey: { what_team_are_you_on: teamFromUrl }, surveySource: 'url' }; + } + if (!useSettingsStore().isCloudDeployment) return undefined; + const cloudPlanStore = useCloudPlanStore(); + // Idempotent; a failed fetch leaves the account empty and logs a warning. + await cloudPlanStore.initialize(); + const team = cloudPlanStore.currentUserCloudInfo?.information?.what_team_are_you_on; + return typeof team === 'string' && team + ? { survey: { what_team_are_you_on: team }, surveySource: 'cloud' } + : undefined; +} + const InstanceAiView = async () => await import('./InstanceAiView.vue'); const InstanceAiEmptyView = async () => await import('./InstanceAiEmptyView.vue'); const InstanceAiThreadView = async () => await import('./InstanceAiThreadView.vue'); @@ -121,6 +151,22 @@ export const InstanceAiModule = defineFrontendModule({ name: INSTANCE_AI_VIEW, path: '', component: InstanceAiEmptyView, + // n8n Cloud sends a new signup to `/assistant?source=onboarding` after its survey. + // The onboarding thread opens with the greeting and the first question card in + // place, so the guard creates it with the survey and lands on it. `&team=Sales` + // answers the team question from the URL, so one account can run it again. + beforeEnter: async (to) => { + if (to.query[INSTANCE_AI_SOURCE_QUERY] !== 'onboarding') return true; + // Whoever cannot use the assistant yet lands on the empty view and its setup. + const projectId = useInstanceAiReady().value ? await ensurePersonalProjectId() : null; + const threadId = projectId + ? await provisionOnboardingThread(projectId, await readSignupSurvey(to.query)) + : null; + // Both redirects drop the query, so the back button creates no second thread. + return threadId + ? { name: INSTANCE_AI_THREAD_VIEW, params: { threadId } } + : { name: INSTANCE_AI_VIEW }; + }, }, { name: INSTANCE_AI_THREADS_VIEW,