mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
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:
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
+1
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
|
||||
+85
-22
@@ -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,
|
||||
});
|
||||
|
||||
+86
-6
@@ -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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user