diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 195ffa9658b..d37d848c501 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -350,6 +350,13 @@ export { InstanceAiGatewayCreateCredentialDto, InstanceAiFilesystemResponseDto, applyBranchReadOnlyOverrides, + normalizeInstanceAiThreadSource, +} from './schemas/instance-ai.schema'; + +export type { + InstanceAiThreadSource, + InstanceAiThreadSourcePersisted, + InstanceAiThreadOrigin, } from './schemas/instance-ai.schema'; export type { diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts index a483e353540..0ce2809c56e 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts @@ -7,6 +7,9 @@ import { InstanceAiAdminSettingsUpdateRequest, instanceAiEventSchema, isDisplayableConfirmationRequest, + InstanceAiEnsureThreadRequest, + normalizeInstanceAiThreadSource, + INSTANCE_AI_THREAD_SOURCE_FALLBACK, isInstanceAiSandboxProvider, parseDomainAccessGrants, WEB_SEARCH_GRANT_KEY, @@ -342,6 +345,37 @@ describe('isDisplayableConfirmationRequest', () => { }); }); +describe('instance-ai launch schema', () => { + it('normalizes a known source', () => { + expect(normalizeInstanceAiThreadSource('template-view')).toBe('template-view'); + }); + + it('falls back for an unknown source', () => { + expect(normalizeInstanceAiThreadSource('totally-made-up')).toBe( + INSTANCE_AI_THREAD_SOURCE_FALLBACK, + ); + expect(normalizeInstanceAiThreadSource(undefined)).toBe(INSTANCE_AI_THREAD_SOURCE_FALLBACK); + }); + + it('parses an ensure-thread request with launch fields', () => { + const parsed = new InstanceAiEnsureThreadRequest({ + projectId: 'project-1', + origin: 'external', + source: 'website-template', + sourceContext: { templateId: '42' }, + }); + expect(parsed.origin).toBe('external'); + expect(parsed.sourceContext).toEqual({ templateId: '42' }); + }); + + it('rejects an oversized sourceContext', () => { + const big = { blob: 'x'.repeat(3000) }; + expect( + () => new InstanceAiEnsureThreadRequest({ projectId: 'project-1', sourceContext: big }), + ).toThrow(); + }); +}); + describe('domain-access grant keys', () => { it('builds and parses per-host grant keys round-trip', () => { const key = buildFetchUrlGrantKey('example.com'); 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 12d101997a9..5566b0b8241 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -875,9 +875,42 @@ export class InstanceAiCorrectTaskRequest extends Z.class({ message: z.string().min(1), }) {} +export const INSTANCE_AI_THREAD_SOURCES = ['website-template', 'template-view'] as const; +export type InstanceAiThreadSource = (typeof INSTANCE_AI_THREAD_SOURCES)[number]; + +export const INSTANCE_AI_THREAD_SOURCE_FALLBACK = 'unknown'; +export type InstanceAiThreadSourcePersisted = + | InstanceAiThreadSource + | typeof INSTANCE_AI_THREAD_SOURCE_FALLBACK; + +export const INSTANCE_AI_THREAD_ORIGINS = ['internal', 'external'] as const; +export type InstanceAiThreadOrigin = (typeof INSTANCE_AI_THREAD_ORIGINS)[number]; + +function isInstanceAiThreadSource(value: string): value is InstanceAiThreadSource { + return (INSTANCE_AI_THREAD_SOURCES as readonly string[]).includes(value); +} + +/** Normalize an untrusted source string to a known value, falling back otherwise. */ +export function normalizeInstanceAiThreadSource( + value: string | undefined, +): InstanceAiThreadSourcePersisted { + return value !== undefined && isInstanceAiThreadSource(value) + ? value + : INSTANCE_AI_THREAD_SOURCE_FALLBACK; +} + +const instanceAiSourceContextSchema = z + .record(z.string(), z.unknown()) + .refine((value) => JSON.stringify(value).length <= 2048, { + message: 'sourceContext exceeds the maximum allowed size', + }); + export class InstanceAiEnsureThreadRequest extends Z.class({ threadId: z.string().uuid().optional(), projectId: z.string().min(1), + source: z.string().max(64).optional(), + origin: z.enum(INSTANCE_AI_THREAD_ORIGINS).optional(), + sourceContext: instanceAiSourceContextSchema.optional(), }) {} export const instanceAiGatewayKeySchema = z.string().min(1).max(256); diff --git a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts index 8eaf5aac193..8aaa3283998 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts @@ -303,6 +303,7 @@ export async function createStubServices( credentialService, nodeService, dataTableService, + workflowTemplateService: { getTemplate: async () => ({ available: false as const }) }, }; return { context, capturedWorkflows }; diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 39cde2d05c2..502be889c44 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -622,6 +622,7 @@ export type { WebSearchResponse, InstanceAiWebResearchService, InstanceAiWorkspaceService, + InstanceAiWorkflowTemplateService, ProjectSummary, FolderSummary, ServiceProxyConfig, diff --git a/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-permissions.test.ts b/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-permissions.test.ts index 57ece1ae042..e8d1a9042c5 100644 --- a/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-permissions.test.ts +++ b/packages/@n8n/instance-ai/src/planned-tasks/__tests__/planned-task-permissions.test.ts @@ -15,6 +15,7 @@ function makeContext( credentialService: {} as InstanceAiContext['credentialService'], nodeService: {} as InstanceAiContext['nodeService'], dataTableService: {} as InstanceAiContext['dataTableService'], + workflowTemplateService: {} as InstanceAiContext['workflowTemplateService'], logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, }; } diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts new file mode 100644 index 00000000000..0e011e20041 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/__tests__/templates.tool.test.ts @@ -0,0 +1,37 @@ +import { executeTool } from '../../__tests__/tool-test-utils'; +import type { InstanceAiContext } from '../../types'; +import { createTemplatesTool } from '../templates.tool'; + +// ── Mock helpers ─────────────────────────────────────────────────────────────── + +function makeContext(getTemplate: (id: string) => Promise): InstanceAiContext { + return { + workflowTemplateService: { getTemplate }, + } as unknown as InstanceAiContext; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('templates tool', () => { + it('returns the template for an id', async () => { + const tool = createTemplatesTool( + // eslint-disable-next-line @typescript-eslint/require-await + makeContext(async (id: string) => ({ available: true, template: { id } })), + ); + + const result = await executeTool(tool, { templateId: '7' }); + + expect(result).toEqual({ available: true, template: { id: '7' } }); + }); + + it('surfaces unavailable templates', async () => { + const tool = createTemplatesTool( + // eslint-disable-next-line @typescript-eslint/require-await + makeContext(async () => ({ available: false as const })), + ); + + const result = await executeTool(tool, { templateId: '7' }); + + expect(result).toEqual({ available: false }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts index d1ef6b69f54..de64a114df9 100644 --- a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts @@ -62,6 +62,9 @@ function createMockContext(overrides?: Partial): InstanceAiCo updateRows: vi.fn(), deleteRows: vi.fn(), }, + workflowTemplateService: { + getTemplate: vi.fn(), + }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, ...overrides, }; diff --git a/packages/@n8n/instance-ai/src/tools/index.ts b/packages/@n8n/instance-ai/src/tools/index.ts index da88fac89de..d6055772926 100644 --- a/packages/@n8n/instance-ai/src/tools/index.ts +++ b/packages/@n8n/instance-ai/src/tools/index.ts @@ -80,6 +80,9 @@ const loadBuildWorkflowTool = lazyMod( const loadWorkflowsTool = lazyMod( () => require('./workflows.tool') as typeof import('./workflows.tool'), ); +const loadTemplatesTool = lazyMod( + () => require('./templates.tool') as typeof import('./templates.tool'), +); const loadWorkspaceTool = lazyMod( () => require('./workspace.tool') as typeof import('./workspace.tool'), ); @@ -102,6 +105,7 @@ export function createAllTools(context: InstanceAiContext): InstanceAiToolRegist [DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)], [DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()], [DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)], + [DOMAIN_TOOL_IDS.TEMPLATES, loadTemplatesTool().createTemplatesTool(context)], ]; if (context.currentUserAttachments?.some(isParseableAttachment)) { @@ -130,6 +134,7 @@ export function createOrchestratorDomainTools(context: InstanceAiContext): Insta [DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)], [DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()], [DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)], + [DOMAIN_TOOL_IDS.TEMPLATES, loadTemplatesTool().createTemplatesTool(context)], ]; if (context.currentUserAttachments?.some(isParseableAttachment)) { diff --git a/packages/@n8n/instance-ai/src/tools/templates.tool.ts b/packages/@n8n/instance-ai/src/tools/templates.tool.ts new file mode 100644 index 00000000000..f73b7d7360c --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/templates.tool.ts @@ -0,0 +1,30 @@ +import { Tool } from '@n8n/agents'; +import { z } from 'zod'; + +import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; +import type { InstanceAiContext } from '../types'; + +// ── Input schema ─────────────────────────────────────────────────────────────── + +const inputSchema = sanitizeInputSchema( + z.object({ + templateId: z.string().describe('The numeric id of the workflow template to load'), + }), +); + +type Input = z.infer; + +// ── Tool factory ─────────────────────────────────────────────────────────────── + +export function createTemplatesTool(context: InstanceAiContext) { + return new Tool('templates') + .description( + 'Load an n8n workflow template by its id. Returns the template workflow ' + + '(nodes and connections) to use as a starting point for building.', + ) + .input(inputSchema) + .handler(async (input: Input) => { + return await context.workflowTemplateService.getTemplate(input.templateId); + }) + .build(); +} diff --git a/packages/@n8n/instance-ai/src/tools/tool-ids.ts b/packages/@n8n/instance-ai/src/tools/tool-ids.ts index 4001491e74c..bf6e20299c9 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 = { ASK_USER: 'ask-user', BUILD_WORKFLOW: 'build-workflow', PARSE_FILE: 'parse-file', + TEMPLATES: 'templates', } as const; /** Trace-only chain-typed child run emitted by `build-workflow` with the @@ -55,6 +56,7 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set([ DOMAIN_TOOL_IDS.NODES, ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW, DOMAIN_TOOL_IDS.RESEARCH, + DOMAIN_TOOL_IDS.TEMPLATES, 'web-search', 'fetch-url', ]); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/apply-workflow-credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/apply-workflow-credentials.tool.test.ts index 764a9285e1d..659c26b3e5a 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/apply-workflow-credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/apply-workflow-credentials.tool.test.ts @@ -42,6 +42,7 @@ function makeContext(options: MakeContextOptions = {}): OrchestrationContext { executionService: {} as never, nodeService: {} as never, dataTableService: {} as never, + workflowTemplateService: {} as never, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, }; diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts index d6e74dec943..acdd6756277 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/resolve-credentials.test.ts @@ -31,6 +31,7 @@ function createMockContext(existingWorkflow?: WorkflowJSON): InstanceAiContext { }, nodeService: {} as InstanceAiContext['nodeService'], dataTableService: {} as InstanceAiContext['dataTableService'], + workflowTemplateService: {} as InstanceAiContext['workflowTemplateService'], logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, }; } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index f0815393a4a..d8a94cd936d 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -73,6 +73,9 @@ function createMockContext(overrides?: Partial): InstanceAiCo updateRows: vi.fn(), deleteRows: vi.fn(), }, + workflowTemplateService: { + getTemplate: vi.fn(), + }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, ...overrides, }; diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index f690573f2a1..c05b364d482 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -825,6 +825,14 @@ export interface InstanceAiWorkspaceService { ): Promise<{ deletedCount: number }>; } +// ── Workflow template service ──────────────────────────────────────────────── + +export interface InstanceAiWorkflowTemplateService { + getTemplate( + templateId: string, + ): Promise<{ available: true; template: Record } | { available: false }>; +} + // ── Builder delegate (sub-agent) ───────────────────────────────────────────── /** Reference to a workflow the current instance-AI session built or touched. */ @@ -904,6 +912,7 @@ export interface InstanceAiContext { /** Curated workflow-template provider — materializes `knowledge-base/templates/` in the sandbox. */ templatesService?: BuilderTemplatesService; workspaceService?: InstanceAiWorkspaceService; + workflowTemplateService: InstanceAiWorkflowTemplateService; /** * Connected remote MCP server (e.g. computer-use daemon). When set, dynamic tools are created from its advertised capabilities. */ diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts index f77e83aac34..4be86039c3a 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts @@ -449,6 +449,102 @@ describe('InstanceAiMemoryService.deleteThread', () => { }); }); +describe('InstanceAiMemoryService.ensureThread launch metadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes source/origin/sourceContext into metadata when creating', async () => { + mockGetThread.mockResolvedValueOnce(null); + mockSaveThreadWithProject.mockResolvedValueOnce({ + id: 'thread-1', + title: '', + resourceId: 'user-1', + metadata: { + source: 'template-view', + origin: 'internal', + sourceContext: { templateId: '42' }, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + const service = createService(); + const result = await service.ensureThread('user-1', 'thread-1', 'project-1', { + source: 'template-view', + origin: 'internal', + sourceContext: { templateId: '42' }, + }); + + expect(mockSaveThreadWithProject).toHaveBeenCalledWith( + { + id: 'thread-1', + resourceId: 'user-1', + title: '', + metadata: { + source: 'template-view', + origin: 'internal', + sourceContext: { templateId: '42' }, + }, + }, + 'project-1', + ); + expect(result.created).toBe(true); + }); + + it('omits sourceContext from metadata when not provided', async () => { + mockGetThread.mockResolvedValueOnce(null); + mockSaveThreadWithProject.mockResolvedValueOnce({ + id: 'thread-2', + title: '', + resourceId: 'user-1', + metadata: { source: 'website-template', origin: 'external' }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + const service = createService(); + await service.ensureThread('user-1', 'thread-2', 'project-1', { + source: 'website-template', + origin: 'external', + }); + + expect(mockSaveThreadWithProject).toHaveBeenCalledWith( + { + id: 'thread-2', + resourceId: 'user-1', + title: '', + metadata: { + source: 'website-template', + origin: 'external', + }, + }, + 'project-1', + ); + }); + + it('does not pass metadata when the thread already exists', async () => { + mockGetThread.mockResolvedValueOnce({ + id: 'thread-existing', + title: 'Existing', + resourceId: 'user-1', + metadata: { foo: 'bar' }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }); + + const service = createService(); + const result = await service.ensureThread('user-1', 'thread-existing', 'project-1', { + source: 'template-view', + origin: 'internal', + sourceContext: { templateId: '42' }, + }); + + expect(result.created).toBe(false); + expect(mockSaveThreadWithProject).not.toHaveBeenCalled(); + }); +}); + describe('InstanceAiMemoryService.deleteThreadsForUser', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index af8f39efbef..e10c8839844 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -58,6 +58,7 @@ import type { RoleService } from '@/services/role.service'; import type { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network'; import type { AiGatewayService } from '@/services/ai-gateway.service'; import type { Telemetry } from '@/telemetry'; +import type { WorkflowTemplatesService } from '../workflow-templates.service'; vi.mock('@/permissions.ee/check-access'); vi.mock('@/workflow-execute-additional-data', () => ({ @@ -154,6 +155,7 @@ const service = new InstanceAiAdapterService( mock(), mock(), mock(), + mock(), ); const user = mock({ diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index d2296134980..c76ac9cd303 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1296,6 +1296,7 @@ function createNodeAdapterServiceForTests( {} as unknown as ConstructorParameters[31], mock() as unknown as ConstructorParameters[32], {} as unknown as ConstructorParameters[33], + {} as unknown as ConstructorParameters[34], nodeCatalogService, ); @@ -1568,6 +1569,7 @@ function createDataTableAdapterForTests(overrides?: { {} as unknown as ConstructorParameters[31], mock() as unknown as ConstructorParameters[32], {} as unknown as ConstructorParameters[33], + {} as unknown as ConstructorParameters[34], ); const adapter = service.createContext(mockUser, { @@ -1893,6 +1895,7 @@ function createWorkflowAdapterForTests(overrides?: { {} as unknown as ConstructorParameters[31], mock() as unknown as ConstructorParameters[32], {} as unknown as ConstructorParameters[33], + {} as unknown as ConstructorParameters[34], ); const boundProjectId = @@ -2646,6 +2649,7 @@ function createExecutionAdapterForTests(overrides?: { sharingEnabled?: boolean } {} as unknown as ConstructorParameters[31], mock() as unknown as ConstructorParameters[32], {} as unknown as ConstructorParameters[33], + {} as unknown as ConstructorParameters[34], ); const adapter = service.createContext(mockUser).executionService; @@ -2907,6 +2911,7 @@ function createRunAdapterForTests( {} as unknown as ConstructorParameters[31], mock() as unknown as ConstructorParameters[32], {} as unknown as ConstructorParameters[33], + {} as unknown as ConstructorParameters[34], ); const adapter = service.createContext(mockUser, { threadId: options?.threadId }).executionService; 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 177dd947ff2..7ab90ae8f88 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 @@ -1010,15 +1010,25 @@ describe('InstanceAiController', () => { projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never); const threadResult = mock(); memoryService.ensureThread.mockResolvedValue(threadResult); + // Launch fields must be explicitly undefined: the deep mock proxies + // absent properties, which would look like a launch to the controller. const payload = mock({ threadId: 'custom-id', projectId: 'project-1', + source: undefined, + origin: undefined, + sourceContext: undefined, }); const result = await controller.ensureThread(req, res, payload); expect(result).toBe(threadResult); - expect(memoryService.ensureThread).toHaveBeenCalledWith(USER_ID, 'custom-id', 'project-1'); + expect(memoryService.ensureThread).toHaveBeenCalledWith( + USER_ID, + 'custom-id', + 'project-1', + undefined, + ); }); it('should generate a UUID when threadId is not provided', async () => { @@ -1028,6 +1038,9 @@ describe('InstanceAiController', () => { const payload = mock({ threadId: undefined, projectId: 'project-1', + source: undefined, + origin: undefined, + sourceContext: undefined, }); await controller.ensureThread(req, res, payload); @@ -1037,9 +1050,31 @@ describe('InstanceAiController', () => { USER_ID, expect.any(String), 'project-1', + undefined, ); }); + it('normalizes and forwards launch metadata when a source is provided', async () => { + memoryService.checkThreadOwnership.mockResolvedValue('not_found'); + projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never); + memoryService.ensureThread.mockResolvedValue(mock()); + const payload = { + threadId: 'custom-id', + projectId: 'project-1', + source: 'not-a-known-source', + sourceContext: { templateId: '6270' }, + } as InstanceAiEnsureThreadRequest; + + await controller.ensureThread(req, res, payload); + + // Unknown sources normalize to the fallback; origin defaults to internal. + expect(memoryService.ensureThread).toHaveBeenCalledWith(USER_ID, 'custom-id', 'project-1', { + source: 'unknown', + origin: 'internal', + sourceContext: { templateId: '6270' }, + }); + }); + it('reports ensure-thread failures to observability before rethrowing', async () => { memoryService.checkThreadOwnership.mockResolvedValue('not_found'); projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never); diff --git a/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts new file mode 100644 index 00000000000..a1b4a6abb65 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts @@ -0,0 +1,82 @@ +import type { Logger } from '@n8n/backend-common'; +import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'vitest-mock-extended'; + +import { + TEMPLATE_REQUEST_TIMEOUT_MS, + WorkflowTemplatesService, +} from '../workflow-templates.service'; + +const request = vi.fn(); +const requests = vi.fn().mockReturnValue(mock({ request })); + +const outboundHttp = mock({ requests }); + +function makeService(enabled = true, host = 'https://api.n8n.io/api/') { + const globalConfig = mock({ templates: { enabled, host } }); + return new WorkflowTemplatesService(mock(), globalConfig, outboundHttp); +} + +describe('WorkflowTemplatesService', () => { + beforeEach(() => { + vi.clearAllMocks(); + requests.mockReturnValue(mock({ request })); + }); + + it('creates the request client with SSRF disabled for the fixed host', () => { + makeService(); + + expect(requests).toHaveBeenCalledWith({ + ssrf: 'disabled', + timeout: TEMPLATE_REQUEST_TIMEOUT_MS, + }); + }); + + it('returns the template workflow for an id', async () => { + request.mockResolvedValue({ workflow: { id: 7, name: 'Demo' } }); + const service = makeService(); + + const result = await service.getTemplate('7'); + + expect(result).toEqual({ available: true, template: { id: 7, name: 'Demo' } }); + expect(request).toHaveBeenCalledWith({ + url: 'https://api.n8n.io/api/templates/workflows/7', + method: 'GET', + headers: { Accept: 'application/json' }, + json: true, + }); + }); + + it('reports unavailable when templates are disabled', async () => { + const service = makeService(false); + const result = await service.getTemplate('7'); + expect(result).toEqual({ available: false }); + expect(request).not.toHaveBeenCalled(); + }); + + it('reports unavailable when a 200 response has no workflow payload', async () => { + request.mockResolvedValue({ error: 'not found' }); + const service = makeService(); + + const result = await service.getTemplate('7'); + + expect(result).toEqual({ available: false }); + }); + + it('throws when the templates host responds with an error status', async () => { + request.mockRejectedValue(new Error('Template request failed with status 502')); + const service = makeService(); + + await expect(service.getTemplate('7')).rejects.toThrow( + 'Template request failed with status 502', + ); + }); + + it('rethrows network failures', async () => { + request.mockRejectedValue(new Error('socket hang up')); + const service = makeService(); + + await expect(service.getTemplate('7')).rejects.toThrow('socket hang up'); + }); +}); 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 c89b8e8f2ab..c9ceec0cc83 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 @@ -4,6 +4,8 @@ import type { InstanceAiThreadInfo, InstanceAiThreadListResponse, InstanceAiThreadMessagesResponse, + InstanceAiThreadOrigin, + InstanceAiThreadSourcePersisted, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; @@ -30,6 +32,12 @@ import { InstanceAiCheckpointRepository } from './repositories/instance-ai-check import { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository'; import { TypeORMAgentMemory } from './storage/typeorm-agent-memory'; +export interface InstanceAiThreadLaunchMetadata { + source: InstanceAiThreadSourcePersisted; + origin: InstanceAiThreadOrigin; + sourceContext?: Record; +} + function isAgentMessageLike(value: unknown): value is AgentDbMessage { return ( typeof value === 'object' && @@ -115,6 +123,7 @@ export class InstanceAiMemoryService { userId: string, threadId: string, projectId: string, + launchMetadata?: InstanceAiThreadLaunchMetadata, ): Promise { const existing = await this.agentMemory.getThread(threadId); if (existing) { @@ -133,6 +142,17 @@ export class InstanceAiMemoryService { id: threadId, resourceId: userId, title: '', + ...(launchMetadata + ? { + metadata: { + source: launchMetadata.source, + origin: launchMetadata.origin, + ...(launchMetadata.sourceContext + ? { sourceContext: launchMetadata.sourceContext } + : {}), + }, + } + : {}), }, projectId, ); 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 acb203e0d87..9e205f7fa3d 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 @@ -9,6 +9,8 @@ import type { InstanceAiNodeService, InstanceAiDataTableService, InstanceAiWebResearchService, + InstanceAiWorkspaceService, + InstanceAiWorkflowTemplateService, FetchedPage, DataTableSummary, DataTableColumnInfo, @@ -30,7 +32,6 @@ import type { AiGatewayNodeMeta, ExploreResourcesParams, ExploreResourcesResult, - InstanceAiWorkspaceService, ProjectSummary, FolderSummary, ServiceProxyConfig, @@ -57,6 +58,7 @@ import { nanoid } from 'nanoid'; import { extractResolvedNodeParameters } from './extract-resolved-node-parameters'; import { InstanceAiSettingsService } from './instance-ai-settings.service'; +import { WorkflowTemplatesService } from './workflow-templates.service'; import { buildInstanceAiRunPinDataPlan, pruneUnreachedVerificationPinData, @@ -255,6 +257,7 @@ export class InstanceAiAdapterService { private readonly ssrfProtectionService: SsrfProtectionService, private readonly outboundHttp: OutboundHttp, private readonly aiGatewayService: AiGatewayService, + private readonly workflowTemplatesService: WorkflowTemplatesService, private readonly nodeCatalogService?: NodeCatalogService, // Optional: absent only in package/test contexts constructed without DI. // DI (by type, not position) always provides it in a running instance. @@ -309,6 +312,7 @@ export class InstanceAiAdapterService { webResearchService: this.createWebResearchAdapter(user, searchProxyConfig), workspaceService: this.createWorkspaceAdapter(user), templatesService: this.getTemplatesService(), + workflowTemplateService: this.createWorkflowTemplateAdapter(), licenseHints: this.buildLicenseHints(), logger: this.logger, nodeTypesProvider: this.nodeTypes, @@ -394,6 +398,15 @@ export class InstanceAiAdapterService { return this.templatesService; } + private createWorkflowTemplateAdapter(): InstanceAiWorkflowTemplateService { + const workflowTemplatesService = this.workflowTemplatesService; + return { + async getTemplate(templateId: string) { + return await workflowTemplatesService.getTemplate(templateId); + }, + }; + } + private buildLicenseHints(): string[] { const hints: string[] = []; if (!this.license.isLicensed('feat:namedVersions')) { 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 a2eadab0b70..96bad9e306f 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -16,6 +16,7 @@ import { InstanceAiEvalExecutionRequest, InstanceAiEvalCredentialAllowlistRequest, InstanceAiEvalRestoreThreadRequest, + normalizeInstanceAiThreadSource, } from '@n8n/api-types'; import type { InstanceAiAgentNode } from '@n8n/api-types'; import { ModuleRegistry } from '@n8n/backend-common'; @@ -595,11 +596,22 @@ export class InstanceAiController { } const requestedThreadId = payload.threadId ?? randomUUID(); await this.assertThreadAccess(req.user.id, requestedThreadId, { allowNew: true }); + + const launchMetadata = + payload.source !== undefined || payload.origin !== undefined + ? { + source: normalizeInstanceAiThreadSource(payload.source), + origin: payload.origin ?? ('internal' as const), + sourceContext: payload.sourceContext, + } + : undefined; + try { return await this.memoryService.ensureThread( req.user.id, requestedThreadId, payload.projectId, + launchMetadata, ); } catch (error) { this.instanceAiErrorReporter.report(error, { diff --git a/packages/cli/src/modules/instance-ai/workflow-templates.service.ts b/packages/cli/src/modules/instance-ai/workflow-templates.service.ts new file mode 100644 index 00000000000..d9e61d506b3 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/workflow-templates.service.ts @@ -0,0 +1,56 @@ +import { Logger } from '@n8n/backend-common'; +import { OutboundHttp, type HttpRequestClient } from '@n8n/backend-network'; +import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; + +export const TEMPLATE_REQUEST_TIMEOUT_MS = 5000; + +export type WorkflowTemplateResult = + | { available: true; template: Record } + | { available: false }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +@Service() +export class WorkflowTemplatesService { + private readonly http: HttpRequestClient; + + constructor( + private readonly logger: Logger, + private readonly globalConfig: GlobalConfig, + outboundHttp: OutboundHttp, + ) { + this.http = outboundHttp.requests({ + ssrf: 'disabled', // Fixed, n8n-controlled templates host. + timeout: TEMPLATE_REQUEST_TIMEOUT_MS, + }); + } + + async getTemplate(templateId: string): Promise { + const { enabled, host } = this.globalConfig.templates; + if (!enabled || !host) { + return { available: false }; + } + + const url = `${host.replace(/\/?$/, '/')}templates/workflows/${encodeURIComponent(templateId)}`; + try { + const body = await this.http.request<{ workflow?: unknown }>({ + url, + method: 'GET', + headers: { Accept: 'application/json' }, + json: true, + }); + const workflow = isRecord(body) ? body.workflow : undefined; + if (!isRecord(workflow)) { + this.logger.warn('Workflow template response missing workflow payload', { templateId }); + return { available: false }; + } + return { available: true, template: workflow }; + } catch (error) { + this.logger.error('Error fetching workflow template', { error, templateId }); + throw error; + } + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7b3594b3300..9e2b16e732d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3927,6 +3927,7 @@ "tagsView.notBeingUsed": "Not being used", "onboarding.title": "Demo: {name}", "template.buttons.goBackButton": "Go back", + "template.buttons.startWithAi": "Customize with AI", "template.buttons.tryTemplate": "Try template", "template.buttons.useThisWorkflowButton": "Use this workflow", "template.details.appsInTheCollection": "This collection features", @@ -6090,6 +6091,8 @@ "instanceAi.statusBar.waitingForInput": "Waiting for your input", "instanceAi.input.send": "Send", "instanceAi.input.stop": "Stop", + "instanceAi.launch.template.message": "I want to start from the workflow template '{name}' (template id: {id}). Load it and give me a short, plain summary: what triggers it, what it does, and which apps it uses. Keep it to a few sentences. Then ask me a few questions specific to this template's steps and apps so I can adapt it, like which of its apps to swap, what to connect, or what behavior to change. Make each question quick to answer: give me numbered choices or ask for a short reply. Don't build anything until I answer.", + "instanceAi.launch.templateById.message": "I want to start from the workflow template with id {id}. Load it and give me a short, plain summary: what triggers it, what it does, and which apps it uses. Keep it to a few sentences. Then ask me a few questions specific to this template's steps and apps so I can adapt it, like which of its apps to swap, what to connect, or what behavior to change. Make each question quick to answer: give me numbered choices or ask for a short reply. Don't build anything until I answer.", "instanceAi.toolCall.input": "Input", "instanceAi.toolCall.output": "Output", "instanceAi.toolCall.running": "Running...", diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts index f46749c2ac1..9258317b1f9 100644 --- a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts @@ -10,7 +10,10 @@ import { ChatModule } from '@/features/ai/chatHub/module.descriptor'; import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor'; import { AgentsModule } from '@/features/agents/module.descriptor'; import { OtelModule } from '@/features/settings/otel/module.descriptor'; -import { INSTANCE_AI_SETTINGS_VIEW } from '@/features/ai/instanceAi/constants'; +import { + INSTANCE_AI_NEW_VIEW, + INSTANCE_AI_SETTINGS_VIEW, +} from '@/features/ai/instanceAi/constants'; import type { FrontendModuleDescription } from '@/app/moduleInitializer/module.types'; import * as modalRegistry from '@/app/moduleInitializer/modalRegistry'; @@ -83,11 +86,12 @@ const checkModuleAvailability = (options: any) => { return false; } - // Settings route is always accessible even when the admin toggle is off; - // other instance-ai routes are disabled. + // When the admin toggle is off, instance-ai routes are disabled except the + // settings route, and the template deep-link route, whose guard falls back + // to the classic template setup instead of losing the user's intent. if (options.to.meta.moduleName === 'instance-ai') { const routeName = options.to.name; - if (routeName !== INSTANCE_AI_SETTINGS_VIEW) { + if (routeName !== INSTANCE_AI_SETTINGS_VIEW && routeName !== INSTANCE_AI_NEW_VIEW) { const enabled = settingsStore.moduleSettings['instance-ai']?.enabled; if (enabled === false) { return false; 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 d47b6a78b0b..471419ff2cd 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -526,7 +526,9 @@ async function syncRouteToStore() { onMounted(() => { enablePanelTransitionsAfterStableRender(); + void syncRouteToStore(); + void nextTick(focusChatInputIfFocusIsIdle); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index 3148f7d3a6b..7df93104eed 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -12,7 +12,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useInstanceAiStore } from './instanceAi.store'; import { useInstanceAiSettingsStore } from './instanceAiSettings.store'; import InstanceAiThreadList from './components/InstanceAiThreadList.vue'; -import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from './constants'; +import { INSTANCE_AI_VIEW, isInstanceAiChatRoute } from './constants'; import { SidebarStateKey } from './instanceAiLayout'; const store = useInstanceAiStore(); @@ -56,10 +56,8 @@ provide(SidebarStateKey, { // Reset to collapsed when leaving the AI chat namespace, so the next entry // starts collapsed by default. Refreshes (which don't trigger the guard) keep // the user's current open/closed state. -const CHAT_ROUTE_NAMES = new Set([INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW]); onBeforeRouteLeave((to) => { - const name = typeof to.name === 'string' ? to.name : undefined; - if (!name || !CHAT_ROUTE_NAMES.has(name)) { + if (!isInstanceAiChatRoute(to.name)) { sidebarCollapsed.value = true; } }); @@ -135,8 +133,14 @@ watch( ); onUnmounted(() => { - store.stopCreditsPushListener(); - settingsStore.stopGatewayPushListener(); + // On a transient remount the new instance mounts before this one unmounts, so + // only tear down when the route actually left the module (isInstanceAiChatRoute). + // Stopping the store-level push listeners on a remount would kill the ones the + // new instance relies on (its start calls no-op while the old one is registered). + if (!isInstanceAiChatRoute(route.name)) { + store.stopCreditsPushListener(); + settingsStore.stopGatewayPushListener(); + } }); 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 50bc5dddbda..a12b87d98e6 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 @@ -1,10 +1,16 @@ import { useRouter } from 'vue-router'; import { v4 as uuidv4 } from 'uuid'; -import type { InstanceAiHandoffContext, InstanceAiWorkflowAttachment } from '@n8n/api-types'; +import type { + InstanceAiHandoffContext, + InstanceAiThreadOrigin, + InstanceAiThreadSource, + InstanceAiWorkflowAttachment, +} from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import type { InstanceAiCredentialContext } from '@/app/composables/useInstanceAiEditorCapability'; import { useToast } from '@/app/composables/useToast'; +import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { INSTANCE_AI_THREAD_VIEW } from '../constants'; import { useInstanceAiStore } from '../instanceAi.store'; @@ -60,6 +66,22 @@ export function buildInstanceAiCredentialHandoffContext( }; } +/** Where a launched thread came from — persisted on the thread and tracked by `syncThread`. */ +export interface InstanceAiThreadLaunch { + source: InstanceAiThreadSource; + origin: InstanceAiThreadOrigin; + sourceContext?: Record; +} + +/** + * Stash the opening message for a thread the current context can't send itself + * (a new tab, a router guard). The destination thread view consumes it after + * hydration + SSE connect (see consumePendingFirstMessage) and sends it there. + */ +export function stashPendingFirstMessage(threadId: string, payload: PendingFirstMessage): void { + localStorage.setItem(pendingFirstMessageKey(threadId), JSON.stringify(payload)); +} + /** * Consume the opening message a new-tab hand-off stashed here. A separate window * can't send it (the destination loads before the BE persists it), so it does. @@ -75,6 +97,40 @@ export function consumePendingFirstMessage(threadId: string): PendingFirstMessag } } +/** Resolve the personal project a launched thread binds to, loading it on first use. */ +export async function ensurePersonalProjectId(): Promise { + const projectsStore = useProjectsStore(); + if (!projectsStore.personalProject) { + try { + await projectsStore.getPersonalProject(); + } catch { + return null; + } + } + return projectsStore.personalProject?.id ?? null; +} + +/** + * Provision a launched thread the destination view will send for: mint the id, + * persist it, and stash the opening message. Shared by the deep-link router + * guard and the new-tab hand-off, which both hand off delivery to the view. + * Returns the thread id, or null if persistence failed. + */ +export async function provisionLaunchedThread( + projectId: string, + payload: PendingFirstMessage, + launch?: InstanceAiThreadLaunch, +): Promise { + const threadId = uuidv4(); + try { + await useInstanceAiStore().syncThread(threadId, projectId, launch); + } catch { + return null; + } + stashPendingFirstMessage(threadId, payload); + return threadId; +} + // One hand-off at a time across all entry points (module-level to share the guard). let handoffInFlight = false; @@ -93,41 +149,48 @@ export function useInstanceAiHandoff() { message: string, attachments?: InstanceAiWorkflowAttachment[], prepare?: (threadId: string) => void, - options?: { newTab?: boolean; context?: InstanceAiHandoffContext }, + options?: { + newTab?: boolean; + context?: InstanceAiHandoffContext; + launch?: InstanceAiThreadLaunch; + }, ): Promise { // Drop re-entrant clicks — each call mints a fresh thread, so spam would duplicate. if (handoffInFlight) return; handoffInFlight = true; try { - const threadId = uuidv4(); - // Open the tab now, inside the click gesture, so it isn't popup-blocked. - const tab = options?.newTab ? window.open('', '_blank') : null; - // Persist the thread on the BE before navigating — `/assistant/:threadId` - // expects an existing thread. - try { - await instanceAiStore.syncThread(threadId, projectId); - } catch { - tab?.close(); - toast.showError(new Error('Failed to start a new thread. Try again.'), 'Open failed'); - return; - } - const route = { name: INSTANCE_AI_THREAD_VIEW, params: { threadId } }; if (options?.newTab) { - // Separate window: the destination's runtime sends the message (see - // consumePendingFirstMessage); sending here races backend persistence. - localStorage.setItem( - pendingFirstMessageKey(threadId), - JSON.stringify({ message, attachments, context: options?.context }), + // Open the tab now, inside the click gesture, so it isn't popup-blocked. + // The destination view sends the stashed message (sending here would + // race backend persistence in the separate window). + const tab = window.open('', '_blank'); + const threadId = await provisionLaunchedThread( + projectId, + { message, attachments, context: options?.context }, + options?.launch, ); + if (!threadId) { + tab?.close(); + toast.showError(new Error('Failed to start a new thread. Try again.'), 'Open failed'); + return; + } + const route = { name: INSTANCE_AI_THREAD_VIEW, params: { threadId } }; if (tab) tab.location.href = router.resolve(route).href; else await router.push(route); // popup blocked → same tab; it consumes the message return; } - // Same tab: seed the runtime, send, and redirect — it survives the in-store nav. + // Same tab: send through a runtime seeded here, which survives the navigation. + const threadId = uuidv4(); + try { + await instanceAiStore.syncThread(threadId, projectId, options?.launch); + } catch { + toast.showError(new Error('Failed to start a new thread. Try again.'), 'Open failed'); + return; + } const thread = instanceAiStore.getOrCreateRuntime(threadId, projectId); prepare?.(threadId); void thread.sendMessage(message, attachments, rootStore.pushRef, options?.context); - await router.push(route); + await router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } }); } finally { handoffInFlight = false; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts index 94868eb66cf..11b61308946 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts @@ -1,9 +1,25 @@ export const INSTANCE_AI_VIEW = 'InstanceAi'; export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread'; export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings'; +export const INSTANCE_AI_NEW_VIEW = 'InstanceAiNew'; export const NEW_CONVERSATION_TITLE = 'New conversation'; export { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; export const BROWSER_USE_CONNECTION_TYPE = 'browser-use'; export const COMPUTER_USE_CONNECTION_TYPE = 'computer-use'; export type BrowserUseConnectionType = typeof BROWSER_USE_CONNECTION_TYPE; export type ComputerUseConnectionType = typeof COMPUTER_USE_CONNECTION_TYPE; + +const INSTANCE_AI_CHAT_ROUTE_NAMES: ReadonlySet = new Set([ + INSTANCE_AI_VIEW, + INSTANCE_AI_THREAD_VIEW, + INSTANCE_AI_NEW_VIEW, +]); + +/** + * True while the route stays inside the chat module. Teardown hooks check this + * because entering the module can transiently remount its layout, and a remount + * must not be mistaken for a real route exit. + */ +export function isInstanceAiChatRoute(name: unknown): boolean { + return typeof name === 'string' && INSTANCE_AI_CHAT_ROUTE_NAMES.has(name); +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts index 2baadd0f263..aaaf3f724ea 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts @@ -9,8 +9,16 @@ import type { InstanceAiConfirmRequest, InstanceAiConfirmResponse, InstanceAiHandoffContext, + InstanceAiThreadOrigin, + InstanceAiThreadSource, } from '@n8n/api-types'; +export interface InstanceAiThreadLaunchInput { + source?: InstanceAiThreadSource; + origin?: InstanceAiThreadOrigin; + sourceContext?: Record; +} + /** * POST /instance-ai/chat/:threadId -> { runId } * Sends a user message. Events arrive separately via the SSE connection. @@ -42,12 +50,13 @@ export async function ensureThread( context: IRestApiContext, threadId: string, projectId: string, + launch?: InstanceAiThreadLaunchInput, ): Promise { return await makeRestApiRequest( context, 'POST', '/instance-ai/threads', - { threadId, projectId }, + { threadId, projectId, ...(launch ?? {}) }, ); } 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 457da06cd24..4cafb16393c 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 @@ -2,8 +2,13 @@ import { defineStore } from 'pinia'; import { ref, computed, inject, provide, shallowReactive, type InjectionKey } from 'vue'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useToast } from '@/app/composables/useToast'; +import { useTelemetry } from '@/app/composables/useTelemetry'; import { UNLIMITED_CREDITS, type InstanceAiThreadSummary } from '@n8n/api-types'; -import { ensureThread, getInstanceAiCredits } from './instanceAi.api'; +import { + ensureThread, + getInstanceAiCredits, + type InstanceAiThreadLaunchInput, +} from './instanceAi.api'; import { usePushConnectionStore } from '@/app/stores/pushConnection.store'; import { useInstanceAiSettingsStore } from './instanceAiSettings.store'; import { @@ -21,6 +26,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { const rootStore = useRootStore(); const instanceAiSettingsStore = useInstanceAiSettingsStore(); const toast = useToast(); + const telemetry = useTelemetry(); const persistedThreadIds = new Set(); // --- Instance-level state --- @@ -167,12 +173,29 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { } } - async function syncThread(threadId: string, projectId: string): Promise { + async function syncThread( + threadId: string, + projectId: string, + launch?: InstanceAiThreadLaunchInput, + ): Promise { if (persistedThreadIds.has(threadId)) return; - const result = await ensureThread(rootStore.restApiContext, threadId, projectId); + const result = await ensureThread(rootStore.restApiContext, threadId, projectId, launch); persistedThreadIds.add(result.thread.id); + if (launch) { + const templateId = launch.sourceContext?.templateId; + telemetry.track('User launched Instance AI thread', { + thread_id: result.thread.id, + instance_id: rootStore.instanceId, + source: launch.source, + origin: launch.origin, + ...(typeof templateId === 'string' || typeof templateId === 'number' + ? { template_id: templateId } + : {}), + }); + } + const existingThread = threads.value.find((thread) => thread.id === threadId); if (existingThread) { existingThread.createdAt = result.thread.createdAt; 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 c0e7bf027e4..83caf5a86c9 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 @@ -4,7 +4,18 @@ import { INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY, INSTANCE_AI_COMPUTER_USE_SETUP_MODAL_KEY, } from '@/app/constants/modals'; -import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_SETTINGS_VIEW } from './constants'; +import { VIEWS } from '@/app/constants'; +import { + INSTANCE_AI_VIEW, + INSTANCE_AI_THREAD_VIEW, + INSTANCE_AI_SETTINGS_VIEW, + INSTANCE_AI_NEW_VIEW, +} from './constants'; +import { + ensurePersonalProjectId, + provisionLaunchedThread, +} from './composables/useInstanceAiHandoff'; +import { useInstanceAiAvailable } from './composables/useInstanceAiAvailability'; import { hasPermission } from '@/app/utils/rbac/permissions'; const InstanceAiView = async () => await import('./InstanceAiView.vue'); @@ -30,6 +41,50 @@ export const InstanceAiModule: FrontendModuleDescription = { middleware: ['authenticated', 'custom'], }, children: [ + { + name: INSTANCE_AI_NEW_VIEW, + path: 'new', + component: InstanceAiEmptyView, + beforeEnter: async (to) => { + // Numeric ids only, so a crafted URL can't inject prompt text. + const raw = to.query.templateId; + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return { name: INSTANCE_AI_VIEW }; + } + const templateId = raw; + + // Same canonical gate as the button and website beacon, so the guard + // never refuses an entry point they advertise. Whoever can't use + // the assistant still gets the template. + if (!useInstanceAiAvailable().value) { + return { name: VIEWS.TEMPLATE_SETUP, params: { id: templateId } }; + } + + // Threads are project-bound; deep links launch into the personal project. + const projectId = await ensurePersonalProjectId(); + if (!projectId) { + return { name: INSTANCE_AI_VIEW }; + } + + // The thread view sends the stashed kickoff after it hydrates, so the + // guard never races the runtime. + const threadId = await provisionLaunchedThread( + projectId, + { + message: i18n.baseText('instanceAi.launch.templateById.message', { + interpolate: { id: templateId }, + }), + }, + { source: 'website-template', origin: 'external', sourceContext: { templateId } }, + ); + if (!threadId) { + return { name: INSTANCE_AI_VIEW }; + } + + // Redirect with no query → URL cleared, back-button won't re-fire this. + return { name: INSTANCE_AI_THREAD_VIEW, params: { threadId } }; + }, + }, { name: INSTANCE_AI_VIEW, path: '', diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/templates.store.ts b/packages/frontend/editor-ui/src/features/workflows/templates/templates.store.ts index bba0927f49d..6f3e1178202 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/templates.store.ts +++ b/packages/frontend/editor-ui/src/features/workflows/templates/templates.store.ts @@ -18,6 +18,7 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; import { useSettingsStore } from '@/app/stores/settings.store'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { useUsersStore } from '@/features/settings/users/users.store'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; @@ -175,6 +176,18 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => { : undefined), ); + // Capabilities beaconed to the website alongside utm_instance, so n8n.io can + // offer instance-aware entry points (e.g. "Customize with AI" on templates) + // only when this user can actually use them on this instance. + const instanceAiAvailable = useInstanceAiAvailable(); + const instanceFeatures = computed(() => { + const features: string[] = []; + if (instanceAiAvailable.value) { + features.push('assistant'); + } + return features; + }); + const websiteTemplateRepositoryParameters = computed(() => { const defaultParameters: Record = { ...TEMPLATES_URLS.UTM_QUERY, @@ -185,6 +198,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => { if (userRole.value) { defaultParameters.utm_user_role = userRole.value; } + if (instanceFeatures.value.length > 0) { + defaultParameters.utm_instance_features = instanceFeatures.value.join(','); + } return new URLSearchParams({ ...defaultParameters, }); diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue b/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue index 121557254e3..86eadb16aa6 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue +++ b/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue @@ -8,6 +8,11 @@ import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { useI18n } from '@n8n/i18n'; +import { + ensurePersonalProjectId, + useInstanceAiHandoff, +} from '@/features/ai/instanceAi/composables/useInstanceAiHandoff'; +import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import WorkflowPreviewHost from '@/app/components/WorkflowPreviewHost.vue'; import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store'; import TemplatesView from './TemplatesView.vue'; @@ -25,6 +30,8 @@ const router = useRouter(); const telemetry = useTelemetry(); const i18n = useI18n(); const documentTitle = useDocumentTitle(); +const instanceAiHandoff = useInstanceAiHandoff(); +const instanceAiAvailable = useInstanceAiAvailable(); const loading = ref(true); const showPreview = ref(true); @@ -52,6 +59,28 @@ const openTemplateSetup = async (id: string, e: PointerEvent) => { }); }; +const startWithAi = async () => { + if (!template.value || !instanceAiAvailable.value) return; + const projectId = await ensurePersonalProjectId(); + if (!projectId) return; + + await instanceAiHandoff.startThread( + projectId, + i18n.baseText('instanceAi.launch.template.message', { + interpolate: { name: template.value.name, id: templateId.value }, + }), + undefined, + undefined, + { + launch: { + source: 'template-view', + origin: 'internal', + sourceContext: { templateId: templateId.value, templateName: template.value.name }, + }, + }, + ); +}; + const scrollToTop = () => { const contentArea = document.getElementById('content'); @@ -167,12 +196,24 @@ const previewDocumentId = computed(() =>
@@ -232,6 +273,45 @@ const previewDocumentId = computed(() => } } +.templateActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing--xs); +} + +// Same layered-background technique as the design system's Ask Assistant +// button; the doubled class selector outranks N8nButton's background rules. +.startWithAi.startWithAi { + // Icon inherits currentColor; the label is repainted with the gradient below. + --button--color: var(--assistant--color--highlight-2); + border: 1px solid transparent; + background: + var(--assistant--button--color--background--gradient) padding-box, + var(--assistant--color--highlight-gradient) border-box; + + &:hover { + background: + var(--assistant--button--color--background--hover) padding-box, + var(--assistant--button--color--background--gradient--hover) padding-box, + var(--assistant--color--highlight-gradient--reverse) border-box; + } + + &:active { + background: + var(--assistant--button--color--background--active) padding-box, + var(--assistant--button--color--background--gradient--active) padding-box, + var(--assistant--color--highlight-gradient--reverse) border-box; + } + + span { + background: var(--assistant--color--highlight-gradient); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } +} + .templateCard { width: 380px; flex-shrink: 0; diff --git a/packages/testing/playwright/tests/e2e/instance-ai/thread-launcher.spec.ts b/packages/testing/playwright/tests/e2e/instance-ai/thread-launcher.spec.ts new file mode 100644 index 00000000000..ffec4b5cf3a --- /dev/null +++ b/packages/testing/playwright/tests/e2e/instance-ai/thread-launcher.spec.ts @@ -0,0 +1,51 @@ +import { test, expect, instanceAiTestConfig, SKIP_PROXY_SETUP_ANNOTATION } from './fixtures'; + +test.use(instanceAiTestConfig); +test.describe( + 'Instance AI thread launcher @capability:proxy', + { + annotation: [{ type: 'owner', description: 'instanceAI' }], + }, + () => { + test( + 'template deep-link creates a thread and auto-sends the kickoff message', + // No recorded LLM expectations for this flow: the auto-sent kickoff is + // allowed to error at the model — the test asserts the user message + // bubble, never the assistant reply, so proxy setup is skipped. + { annotation: [{ type: SKIP_PROXY_SETUP_ANNOTATION }] }, + async ({ n8n }) => { + // Boot the app in an authenticated state before following the + // deep-link route, mimicking a user coming from the n8n website. + await n8n.navigate.toInstanceAi(); + + await n8n.page.goto('/assistant/new?templateId=1234'); + + // The router guard provisions a thread and redirects with no query — + // the URL settles on /assistant/, back-button safe. + await expect(n8n.page).toHaveURL(/\/assistant\/[0-9a-f-]+$/, { timeout: 15_000 }); + + // The kickoff auto-sends once the thread view has hydrated and + // connected. It names the template by id. + await expect(n8n.instanceAi.getUserMessages().first()).toContainText('template', { + timeout: 30_000, + }); + await expect(n8n.instanceAi.getUserMessages().first()).toContainText('1234'); + }, + ); + + test( + 'invalid template id lands on the assistant empty view', + { annotation: [{ type: SKIP_PROXY_SETUP_ANNOTATION }] }, + async ({ n8n }) => { + await n8n.navigate.toInstanceAi(); + + // Non-numeric template ids are rejected by the guard: no thread is + // created and the user lands on the assistant empty view. + await n8n.page.goto('/assistant/new?templateId=abc'); + + await expect(n8n.page).toHaveURL(/\/assistant$/, { timeout: 15_000 }); + await expect(n8n.instanceAi.getChatInput()).toBeVisible(); + }, + ); + }, +);