feat(editor): Start Instance AI threads from templates on the website and template page (#33653)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Filipe Tavares
2026-07-13 16:41:54 +01:00
committed by GitHub
parent 67b9cf0930
commit 67b3ed11fe
36 changed files with 860 additions and 45 deletions
+7
View File
@@ -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 {
@@ -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');
@@ -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);
@@ -303,6 +303,7 @@ export async function createStubServices(
credentialService,
nodeService,
dataTableService,
workflowTemplateService: { getTemplate: async () => ({ available: false as const }) },
};
return { context, capturedWorkflows };
+1
View File
@@ -622,6 +622,7 @@ export type {
WebSearchResponse,
InstanceAiWebResearchService,
InstanceAiWorkspaceService,
InstanceAiWorkflowTemplateService,
ProjectSummary,
FolderSummary,
ServiceProxyConfig,
@@ -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,
};
}
@@ -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<unknown>): 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 });
});
});
@@ -62,6 +62,9 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): 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,
};
@@ -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)) {
@@ -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<typeof inputSchema>;
// ── 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();
}
@@ -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<string>([
DOMAIN_TOOL_IDS.NODES,
ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW,
DOMAIN_TOOL_IDS.RESEARCH,
DOMAIN_TOOL_IDS.TEMPLATES,
'web-search',
'fetch-url',
]);
@@ -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,
};
@@ -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,
};
}
@@ -73,6 +73,9 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): 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,
};
+9
View File
@@ -825,6 +825,14 @@ export interface InstanceAiWorkspaceService {
): Promise<{ deletedCount: number }>;
}
// ── Workflow template service ────────────────────────────────────────────────
export interface InstanceAiWorkflowTemplateService {
getTemplate(
templateId: string,
): Promise<{ available: true; template: Record<string, unknown> } | { 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.
*/
@@ -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();
@@ -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<SsrfProtectionService>(),
mock<OutboundHttp>(),
mock<AiGatewayService>(),
mock<WorkflowTemplatesService>(),
);
const user = mock<User>({
@@ -1296,6 +1296,7 @@ function createNodeAdapterServiceForTests(
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
nodeCatalogService,
);
@@ -1568,6 +1569,7 @@ function createDataTableAdapterForTests(overrides?: {
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
);
const adapter = service.createContext(mockUser, {
@@ -1893,6 +1895,7 @@ function createWorkflowAdapterForTests(overrides?: {
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
);
const boundProjectId =
@@ -2646,6 +2649,7 @@ function createExecutionAdapterForTests(overrides?: { sharingEnabled?: boolean }
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
);
const adapter = service.createContext(mockUser).executionService;
@@ -2907,6 +2911,7 @@ function createRunAdapterForTests(
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
);
const adapter = service.createContext(mockUser, { threadId: options?.threadId }).executionService;
@@ -1010,15 +1010,25 @@ describe('InstanceAiController', () => {
projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
const threadResult = mock<InstanceAiEnsureThreadResponse>();
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<InstanceAiEnsureThreadRequest>({
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<InstanceAiEnsureThreadRequest>({
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<InstanceAiEnsureThreadResponse>());
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);
@@ -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<HttpRequestClient>({ request }));
const outboundHttp = mock<OutboundHttp>({ requests });
function makeService(enabled = true, host = 'https://api.n8n.io/api/') {
const globalConfig = mock<GlobalConfig>({ templates: { enabled, host } });
return new WorkflowTemplatesService(mock<Logger>(), globalConfig, outboundHttp);
}
describe('WorkflowTemplatesService', () => {
beforeEach(() => {
vi.clearAllMocks();
requests.mockReturnValue(mock<HttpRequestClient>({ 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');
});
});
@@ -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<string, unknown>;
}
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<InstanceAiEnsureThreadResponse> {
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,
);
@@ -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')) {
@@ -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, {
@@ -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<string, unknown> }
| { available: false };
function isRecord(value: unknown): value is Record<string, unknown> {
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<WorkflowTemplateResult> {
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;
}
}
}
@@ -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...",
@@ -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;
@@ -526,7 +526,9 @@ async function syncRouteToStore() {
onMounted(() => {
enablePanelTransitionsAfterStableRender();
void syncRouteToStore();
void nextTick(focusChatInputIfFocusIsIdle);
});
@@ -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<string>([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();
}
});
</script>
@@ -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<string, unknown>;
}
/**
* 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<string | null> {
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<string | null> {
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<void> {
// 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;
}
@@ -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<string> = 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);
}
@@ -9,8 +9,16 @@ import type {
InstanceAiConfirmRequest,
InstanceAiConfirmResponse,
InstanceAiHandoffContext,
InstanceAiThreadOrigin,
InstanceAiThreadSource,
} from '@n8n/api-types';
export interface InstanceAiThreadLaunchInput {
source?: InstanceAiThreadSource;
origin?: InstanceAiThreadOrigin;
sourceContext?: Record<string, unknown>;
}
/**
* 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<InstanceAiEnsureThreadResponse> {
return await makeRestApiRequest<InstanceAiEnsureThreadResponse>(
context,
'POST',
'/instance-ai/threads',
{ threadId, projectId },
{ threadId, projectId, ...(launch ?? {}) },
);
}
@@ -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<string>();
// --- Instance-level state ---
@@ -167,12 +173,29 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
}
}
async function syncThread(threadId: string, projectId: string): Promise<void> {
async function syncThread(
threadId: string,
projectId: string,
launch?: InstanceAiThreadLaunchInput,
): Promise<void> {
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;
@@ -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: '',
@@ -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<string, string> = {
...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,
});
@@ -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(() =>
<div :class="$style.templateCard">
<RecommendedTemplateCard v-if="template" :template="template" :show-details="true">
<template #belowContent>
<N8nButton
data-test-id="use-template-button"
:label="i18n.baseText('template.buttons.tryTemplate')"
size="large"
@click.stop="openTemplateSetup(templateId, $event)"
/>
<div :class="$style.templateActions">
<N8nButton
data-test-id="use-template-button"
:label="i18n.baseText('template.buttons.tryTemplate')"
size="large"
@click.stop="openTemplateSetup(templateId, $event)"
/>
<N8nButton
v-if="instanceAiAvailable"
data-test-id="start-with-ai-button"
:class="$style.startWithAi"
:label="i18n.baseText('template.buttons.startWithAi')"
variant="ghost"
icon="sparkles"
size="large"
@click.stop="startWithAi"
/>
</div>
</template>
</RecommendedTemplateCard>
</div>
@@ -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;
@@ -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/<uuid>, 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();
},
);
},
);