mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
feat: Add metering for builder (no-changelog) (#19842)
This commit is contained in:
parent
0b7db24070
commit
c449e9e9c8
@ -8,4 +8,9 @@ export default defineConfig(nodeConfig, {
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
},
|
||||
}, {
|
||||
files: ['./src/test/**/*.ts', './**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,95 +1,119 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
|
||||
import { Client } from 'langsmith';
|
||||
import { AiAssistantClient, AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
||||
import assert from 'assert';
|
||||
import { Client as TracingClient } from 'langsmith';
|
||||
import { INodeTypes } from 'n8n-workflow';
|
||||
import type { IUser, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { LLMServiceError } from './errors';
|
||||
import { anthropicClaudeSonnet4, gpt41mini } from './llm-config';
|
||||
import { WorkflowBuilderAgent, type ChatPayload } from './workflow-builder-agent';
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { anthropicClaudeSonnet4 } from '@/llm-config';
|
||||
import { SessionManagerService } from '@/session-manager.service';
|
||||
import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent';
|
||||
|
||||
type OnCreditsUpdated = (userId: string, creditsQuota: number, creditsClaimed: number) => void;
|
||||
|
||||
@Service()
|
||||
export class AiWorkflowBuilderService {
|
||||
private parsedNodeTypes: INodeTypeDescription[] = [];
|
||||
|
||||
private llmSimpleTask: BaseChatModel | undefined;
|
||||
|
||||
private llmComplexTask: BaseChatModel | undefined;
|
||||
|
||||
private tracingClient: Client | undefined;
|
||||
|
||||
private checkpointer = new MemorySaver();
|
||||
|
||||
private agent: WorkflowBuilderAgent | undefined;
|
||||
private sessionManager: SessionManagerService;
|
||||
|
||||
constructor(
|
||||
private readonly nodeTypes: INodeTypes,
|
||||
private readonly client?: AiAssistantClient,
|
||||
private readonly logger?: Logger,
|
||||
private readonly instanceUrl?: string,
|
||||
private readonly onCreditsUpdated?: OnCreditsUpdated,
|
||||
) {
|
||||
this.parsedNodeTypes = this.getNodeTypes();
|
||||
this.sessionManager = new SessionManagerService(this.parsedNodeTypes, logger);
|
||||
}
|
||||
|
||||
private async setupModels(user?: IUser) {
|
||||
try {
|
||||
if (this.llmSimpleTask && this.llmComplexTask) {
|
||||
return;
|
||||
}
|
||||
private static async getAnthropicClaudeModel({
|
||||
baseUrl,
|
||||
authHeaders = {},
|
||||
apiKey = '-',
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
authHeaders?: Record<string, string>;
|
||||
apiKey?: string;
|
||||
} = {}): Promise<ChatAnthropic> {
|
||||
return await anthropicClaudeSonnet4({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
headers: {
|
||||
...authHeaders,
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getApiProxyAuthHeaders(user: IUser, useDeprecatedCredentials = false) {
|
||||
assert(this.client);
|
||||
|
||||
let authHeaders: { Authorization: string };
|
||||
|
||||
if (useDeprecatedCredentials) {
|
||||
const authResponse = await this.client.generateApiProxyCredentials(user);
|
||||
authHeaders = { Authorization: authResponse.apiKey };
|
||||
} else {
|
||||
const authResponse = await this.client.getBuilderApiProxyToken(user);
|
||||
authHeaders = {
|
||||
Authorization: `${authResponse.tokenType} ${authResponse.accessToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
return authHeaders;
|
||||
}
|
||||
|
||||
private async setupModels(
|
||||
user: IUser,
|
||||
useDeprecatedCredentials = false,
|
||||
): Promise<{
|
||||
anthropicClaude: ChatAnthropic;
|
||||
tracingClient?: TracingClient;
|
||||
authHeaders?: { Authorization: string };
|
||||
}> {
|
||||
try {
|
||||
// If client is provided, use it for API proxy
|
||||
if (this.client && user) {
|
||||
const authHeaders = await this.client.generateApiProxyCredentials(user);
|
||||
if (this.client) {
|
||||
const authHeaders = await this.getApiProxyAuthHeaders(user, useDeprecatedCredentials);
|
||||
|
||||
// Extract baseUrl from client configuration
|
||||
const baseUrl = this.client.getApiProxyBaseUrl();
|
||||
|
||||
this.llmSimpleTask = await gpt41mini({
|
||||
baseUrl: baseUrl + '/openai',
|
||||
// When using api-proxy the key will be populated automatically, we just need to pass a placeholder
|
||||
apiKey: '-',
|
||||
headers: {
|
||||
Authorization: authHeaders.apiKey,
|
||||
},
|
||||
});
|
||||
this.llmComplexTask = await anthropicClaudeSonnet4({
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
|
||||
baseUrl: baseUrl + '/anthropic',
|
||||
apiKey: '-',
|
||||
headers: {
|
||||
Authorization: authHeaders.apiKey,
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
authHeaders,
|
||||
});
|
||||
|
||||
this.tracingClient = new Client({
|
||||
const tracingClient = new TracingClient({
|
||||
apiKey: '-',
|
||||
apiUrl: baseUrl + '/langsmith',
|
||||
autoBatchTracing: false,
|
||||
traceBatchConcurrency: 1,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
Authorization: authHeaders.apiKey,
|
||||
...authHeaders,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return { tracingClient, anthropicClaude, authHeaders };
|
||||
}
|
||||
|
||||
// If base URL is not set, use environment variables
|
||||
this.llmSimpleTask = await gpt41mini({
|
||||
apiKey: process.env.N8N_AI_OPENAI_API_KEY ?? '',
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
|
||||
apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '',
|
||||
});
|
||||
|
||||
this.llmComplexTask = await anthropicClaudeSonnet4({
|
||||
apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '',
|
||||
headers: {
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
});
|
||||
return { anthropicClaude };
|
||||
} catch (error) {
|
||||
const llmError = new LLMServiceError('Failed to connect to LLM Provider', {
|
||||
const errorMessage = error instanceof Error ? `: ${error.message}` : '';
|
||||
const llmError = new LLMServiceError(`Failed to connect to LLM Provider${errorMessage}`, {
|
||||
cause: error,
|
||||
tags: {
|
||||
hasClient: !!this.client,
|
||||
@ -144,33 +168,57 @@ export class AiWorkflowBuilderService {
|
||||
return nodeTypes;
|
||||
}
|
||||
|
||||
private async getAgent(user?: IUser) {
|
||||
if (!this.llmComplexTask || !this.llmSimpleTask) {
|
||||
await this.setupModels(user);
|
||||
}
|
||||
private async getAgent(user: IUser, useDeprecatedCredentials = false) {
|
||||
const { anthropicClaude, tracingClient, authHeaders } = await this.setupModels(
|
||||
user,
|
||||
useDeprecatedCredentials,
|
||||
);
|
||||
|
||||
if (!this.llmComplexTask || !this.llmSimpleTask) {
|
||||
throw new LLMServiceError('Failed to initialize LLM models');
|
||||
}
|
||||
|
||||
this.agent ??= new WorkflowBuilderAgent({
|
||||
const agent = new WorkflowBuilderAgent({
|
||||
parsedNodeTypes: this.parsedNodeTypes,
|
||||
// We use Sonnet both for simple and complex tasks
|
||||
llmSimpleTask: this.llmComplexTask,
|
||||
llmComplexTask: this.llmComplexTask,
|
||||
llmSimpleTask: anthropicClaude,
|
||||
llmComplexTask: anthropicClaude,
|
||||
logger: this.logger,
|
||||
checkpointer: this.checkpointer,
|
||||
tracer: this.tracingClient
|
||||
? new LangChainTracer({ client: this.tracingClient, projectName: 'n8n-workflow-builder' })
|
||||
checkpointer: this.sessionManager.getCheckpointer(),
|
||||
tracer: tracingClient
|
||||
? new LangChainTracer({ client: tracingClient, projectName: 'n8n-workflow-builder' })
|
||||
: undefined,
|
||||
instanceUrl: this.instanceUrl,
|
||||
onGenerationSuccess: async () => {
|
||||
if (!useDeprecatedCredentials) {
|
||||
await this.onGenerationSuccess(user, authHeaders);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this.agent;
|
||||
return agent;
|
||||
}
|
||||
|
||||
async *chat(payload: ChatPayload, user?: IUser, abortSignal?: AbortSignal) {
|
||||
const agent = await this.getAgent(user);
|
||||
private async onGenerationSuccess(
|
||||
user?: IUser,
|
||||
authHeaders?: { Authorization: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (this.client) {
|
||||
assert(authHeaders, 'Auth headers must be set when AI Assistant Service client is used');
|
||||
assert(user);
|
||||
const creditsInfo = await this.client.markBuilderSuccess(user, authHeaders);
|
||||
|
||||
// Call the callback with the credits info from the response
|
||||
if (this.onCreditsUpdated && user.id && creditsInfo) {
|
||||
this.onCreditsUpdated(user.id, creditsInfo.creditsQuota, creditsInfo.creditsClaimed);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
this.logger?.error(`Unable to mark generation success ${error.message}`, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async *chat(payload: ChatPayload, user: IUser, abortSignal?: AbortSignal) {
|
||||
const agent = await this.getAgent(user, payload.useDeprecatedCredentials);
|
||||
|
||||
for await (const output of agent.chat(payload, user?.id?.toString(), abortSignal)) {
|
||||
yield output;
|
||||
@ -178,7 +226,21 @@ export class AiWorkflowBuilderService {
|
||||
}
|
||||
|
||||
async getSessions(workflowId: string | undefined, user?: IUser) {
|
||||
const agent = await this.getAgent(user);
|
||||
return await agent.getSessions(workflowId, user?.id?.toString());
|
||||
const userId = user?.id?.toString();
|
||||
return await this.sessionManager.getSessions(workflowId, userId);
|
||||
}
|
||||
|
||||
async getBuilderInstanceCredits(
|
||||
user: IUser,
|
||||
): Promise<AiAssistantSDK.BuilderInstanceCreditsResponse> {
|
||||
if (this.client) {
|
||||
return await this.client.getBuilderInstanceCredits(user);
|
||||
}
|
||||
|
||||
// if using env variables directly instead of ai proxy service
|
||||
return {
|
||||
creditsQuota: -1,
|
||||
creditsClaimed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import { RunnableConfig } from '@langchain/core/runnables';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { formatMessages } from '@/utils/stream-processor';
|
||||
|
||||
import { getBuilderToolsForDisplay } from './tools/builder-tools';
|
||||
import { isLangchainMessagesArray, LangchainMessage, Session } from './types/sessions';
|
||||
|
||||
@Service()
|
||||
export class SessionManagerService {
|
||||
private checkpointer: MemorySaver;
|
||||
|
||||
constructor(
|
||||
private readonly parsedNodeTypes: INodeTypeDescription[],
|
||||
private readonly logger?: Logger,
|
||||
) {
|
||||
this.checkpointer = new MemorySaver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a thread ID for a given workflow and user
|
||||
*/
|
||||
static generateThreadId(workflowId?: string, userId?: string): string {
|
||||
return workflowId
|
||||
? `workflow-${workflowId}-user-${userId ?? new Date().getTime()}`
|
||||
: crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the checkpointer instance
|
||||
*/
|
||||
getCheckpointer(): MemorySaver {
|
||||
return this.checkpointer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a given workflow and user
|
||||
*/
|
||||
async getSessions(
|
||||
workflowId: string | undefined,
|
||||
userId: string | undefined,
|
||||
): Promise<{ sessions: Session[] }> {
|
||||
// For now, we'll return the current session if we have a workflowId
|
||||
// MemorySaver doesn't expose a way to list all threads, so we'll need to
|
||||
// track this differently if we want to list all sessions
|
||||
const sessions: Session[] = [];
|
||||
|
||||
if (workflowId) {
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
const threadConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to get the checkpoint for this thread
|
||||
const checkpoint = await this.checkpointer.getTuple(threadConfig);
|
||||
|
||||
if (checkpoint?.checkpoint) {
|
||||
const rawMessages = checkpoint.checkpoint.channel_values?.messages;
|
||||
const messages: LangchainMessage[] = isLangchainMessagesArray(rawMessages)
|
||||
? rawMessages
|
||||
: [];
|
||||
|
||||
sessions.push({
|
||||
sessionId: threadId,
|
||||
messages: formatMessages(
|
||||
messages,
|
||||
getBuilderToolsForDisplay({
|
||||
nodeTypes: this.parsedNodeTypes,
|
||||
}),
|
||||
),
|
||||
lastUpdated: checkpoint.checkpoint.ts,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Thread doesn't exist yet
|
||||
this.logger?.debug('No session found for workflow:', { workflowId, error });
|
||||
}
|
||||
}
|
||||
|
||||
return { sessions };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,716 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { Client as TracingClient } from 'langsmith';
|
||||
import type { IUser, INodeTypes, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { AiWorkflowBuilderService } from '@/ai-workflow-builder-agent.service';
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { anthropicClaudeSonnet4 } from '@/llm-config';
|
||||
import { SessionManagerService } from '@/session-manager.service';
|
||||
import { formatMessages } from '@/utils/stream-processor';
|
||||
import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@langchain/anthropic');
|
||||
jest.mock('@langchain/langgraph');
|
||||
jest.mock('langsmith');
|
||||
jest.mock('@/workflow-builder-agent');
|
||||
jest.mock('@/session-manager.service');
|
||||
jest.mock('@/llm-config', () => ({
|
||||
anthropicClaudeSonnet4: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/utils/stream-processor', () => ({
|
||||
formatMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
const MockedChatAnthropic = ChatAnthropic as jest.MockedClass<typeof ChatAnthropic>;
|
||||
const MockedMemorySaver = MemorySaver as jest.MockedClass<typeof MemorySaver>;
|
||||
const MockedTracingClient = TracingClient as jest.MockedClass<typeof TracingClient>;
|
||||
const MockedWorkflowBuilderAgent = WorkflowBuilderAgent as jest.MockedClass<
|
||||
typeof WorkflowBuilderAgent
|
||||
>;
|
||||
const MockedSessionManagerService = SessionManagerService as jest.MockedClass<
|
||||
typeof SessionManagerService
|
||||
>;
|
||||
|
||||
const anthropicClaudeSonnet4Mock = anthropicClaudeSonnet4 as jest.MockedFunction<
|
||||
typeof anthropicClaudeSonnet4
|
||||
>;
|
||||
const formatMessagesMock = formatMessages as jest.MockedFunction<typeof formatMessages>;
|
||||
|
||||
describe('AiWorkflowBuilderService', () => {
|
||||
let service: AiWorkflowBuilderService;
|
||||
let mockNodeTypes: INodeTypes;
|
||||
let mockClient: AiAssistantClient;
|
||||
let mockLogger: Logger;
|
||||
let mockUser: IUser;
|
||||
let mockChatAnthropic: ChatAnthropic;
|
||||
let mockTracingClient: TracingClient;
|
||||
let mockMemorySaver: MemorySaver;
|
||||
let mockSessionManager: SessionManagerService;
|
||||
let mockOnCreditsUpdated: jest.Mock;
|
||||
|
||||
const mockNodeTypeDescriptions: INodeTypeDescription[] = [
|
||||
{
|
||||
name: 'TestNode',
|
||||
displayName: 'Test Node',
|
||||
description: 'A test node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
group: ['transform'],
|
||||
} as INodeTypeDescription,
|
||||
{
|
||||
name: 'HiddenNode',
|
||||
displayName: 'Hidden Node',
|
||||
description: 'A hidden node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
group: ['transform'],
|
||||
hidden: true,
|
||||
} as INodeTypeDescription,
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
displayName: 'Tool Vector Store',
|
||||
description: 'An ignored tool node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
group: ['transform'],
|
||||
} as INodeTypeDescription,
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock node types
|
||||
mockNodeTypes = mock<INodeTypes>();
|
||||
(mockNodeTypes.getKnownTypes as jest.Mock).mockReturnValue({
|
||||
TestNode: { type: {} },
|
||||
HiddenNode: { type: {} },
|
||||
'@n8n/n8n-nodes-langchain.toolVectorStore': { type: {} },
|
||||
});
|
||||
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation((name: string) => {
|
||||
const nodeType = mockNodeTypeDescriptions.find((n) => n.name === name);
|
||||
if (nodeType) {
|
||||
return { description: nodeType };
|
||||
}
|
||||
throw new Error(`Node type ${name} not found`);
|
||||
});
|
||||
|
||||
// Mock AI assistant client
|
||||
mockClient = mock<AiAssistantClient>();
|
||||
(mockClient.generateApiProxyCredentials as jest.Mock).mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
(mockClient.getBuilderApiProxyToken as jest.Mock).mockResolvedValue({
|
||||
tokenType: 'Bearer',
|
||||
accessToken: 'test-access-token',
|
||||
});
|
||||
(mockClient.getApiProxyBaseUrl as jest.Mock).mockReturnValue('https://api.example.com');
|
||||
(mockClient.markBuilderSuccess as jest.Mock).mockResolvedValue({
|
||||
creditsQuota: 10,
|
||||
creditsClaimed: 1,
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
mockLogger = mock<Logger>();
|
||||
|
||||
// Mock user
|
||||
mockUser = mock<IUser>();
|
||||
mockUser.id = 'test-user-id';
|
||||
|
||||
// Mock ChatAnthropic
|
||||
mockChatAnthropic = mock<ChatAnthropic>();
|
||||
MockedChatAnthropic.mockImplementation(() => mockChatAnthropic);
|
||||
|
||||
// Mock TracingClient
|
||||
mockTracingClient = mock<TracingClient>();
|
||||
MockedTracingClient.mockImplementation(() => mockTracingClient);
|
||||
|
||||
// Mock MemorySaver
|
||||
mockMemorySaver = mock<MemorySaver>();
|
||||
MockedMemorySaver.mockImplementation(() => mockMemorySaver);
|
||||
|
||||
// Mock SessionManagerService
|
||||
mockSessionManager = mock<SessionManagerService>();
|
||||
(mockSessionManager.getCheckpointer as jest.Mock).mockReturnValue(mockMemorySaver);
|
||||
MockedSessionManagerService.mockImplementation(() => mockSessionManager);
|
||||
|
||||
// Mock WorkflowBuilderAgent
|
||||
MockedWorkflowBuilderAgent.mockImplementation(() => {
|
||||
const mockAgent = mock<WorkflowBuilderAgent>();
|
||||
(mockAgent.chat as jest.Mock).mockImplementation(async function* () {
|
||||
yield { messages: [{ role: 'assistant', type: 'message', text: 'Test response' }] };
|
||||
});
|
||||
return mockAgent;
|
||||
});
|
||||
|
||||
anthropicClaudeSonnet4Mock.mockResolvedValue(mockChatAnthropic);
|
||||
|
||||
// Mock onCreditsUpdated callback
|
||||
mockOnCreditsUpdated = jest.fn();
|
||||
|
||||
// Create service instance
|
||||
service = new AiWorkflowBuilderService(
|
||||
mockNodeTypes,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
'https://n8n.example.com',
|
||||
mockOnCreditsUpdated,
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with provided dependencies', () => {
|
||||
const testService = new AiWorkflowBuilderService(
|
||||
mockNodeTypes,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
'https://test.com',
|
||||
mockOnCreditsUpdated,
|
||||
);
|
||||
|
||||
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
|
||||
expect(mockNodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||
expect(MockedSessionManagerService).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'TestNode' })]),
|
||||
mockLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize without optional dependencies', () => {
|
||||
const testService = new AiWorkflowBuilderService(mockNodeTypes);
|
||||
|
||||
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
|
||||
expect(MockedSessionManagerService).toHaveBeenCalledWith(expect.any(Array), undefined);
|
||||
});
|
||||
|
||||
it('should filter out ignored and hidden node types', () => {
|
||||
// The service filters ignored types at the filter stage, not at getByNameAndVersion stage
|
||||
// Hidden nodes are filtered out after being retrieved in the filter() call
|
||||
expect(mockNodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||
expect(mockNodeTypes.getByNameAndVersion).toHaveBeenCalledWith('TestNode');
|
||||
// Hidden nodes are still retrieved but filtered out later, so this call happens
|
||||
expect(mockNodeTypes.getByNameAndVersion).toHaveBeenCalledWith('HiddenNode');
|
||||
// Ignored types are filtered out before getByNameAndVersion call
|
||||
expect(mockNodeTypes.getByNameAndVersion).not.toHaveBeenCalledWith(
|
||||
'@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when getting node types', () => {
|
||||
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Node type error');
|
||||
});
|
||||
|
||||
// Should not throw when constructing, but should log error
|
||||
const testService = new AiWorkflowBuilderService(mockNodeTypes, mockClient, mockLogger);
|
||||
|
||||
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error getting node type', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle tool node merging correctly when calling chat', async () => {
|
||||
// Setup node types that include tool nodes
|
||||
(mockNodeTypes.getKnownTypes as jest.Mock).mockReturnValue({
|
||||
HttpRequestTool: { type: {} },
|
||||
HttpRequest: { type: {} },
|
||||
});
|
||||
|
||||
const httpRequestToolNode: INodeTypeDescription = {
|
||||
name: 'HttpRequestTool',
|
||||
displayName: 'HTTP Request Tool',
|
||||
description: 'Tool version of HTTP Request',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [
|
||||
{
|
||||
name: 'toolProp',
|
||||
type: 'string',
|
||||
displayName: '',
|
||||
default: undefined,
|
||||
},
|
||||
],
|
||||
group: ['transform'],
|
||||
};
|
||||
|
||||
const httpRequestNode: INodeTypeDescription = {
|
||||
name: 'HttpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
description: 'Regular HTTP Request node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [
|
||||
{
|
||||
name: 'regularProp',
|
||||
type: 'string',
|
||||
displayName: '',
|
||||
default: undefined,
|
||||
},
|
||||
],
|
||||
group: ['transform'],
|
||||
};
|
||||
|
||||
(mockNodeTypes.getByNameAndVersion as jest.Mock).mockImplementation((name: string) => {
|
||||
if (name === 'HttpRequestTool') return { description: httpRequestToolNode };
|
||||
if (name === 'HttpRequest') return { description: httpRequestNode };
|
||||
throw new Error(`Node type ${name} not found`);
|
||||
});
|
||||
|
||||
// Get the mocked WorkflowBuilderAgent constructor
|
||||
const MockedWorkflowBuilderAgent = WorkflowBuilderAgent as jest.MockedClass<
|
||||
typeof WorkflowBuilderAgent
|
||||
>;
|
||||
|
||||
// Clear previous calls and setup return value
|
||||
MockedWorkflowBuilderAgent.mockClear();
|
||||
|
||||
const testService = new AiWorkflowBuilderService(mockNodeTypes, mockClient, mockLogger);
|
||||
|
||||
const payload: ChatPayload = {
|
||||
message: 'Create a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'test-workflow' },
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
|
||||
// Call chat to trigger agent creation
|
||||
const generator = testService.chat(payload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Verify WorkflowBuilderAgent was called with merged node types
|
||||
expect(MockedWorkflowBuilderAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedNodeTypes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'HttpRequestTool',
|
||||
displayName: 'HTTP Request Tool',
|
||||
// Should have tool properties (since tool node overrides regular node in merge)
|
||||
properties: [
|
||||
{ name: 'toolProp', type: 'string', displayName: '', default: undefined },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Also verify that the merged node has the correct structure
|
||||
const actualCall = MockedWorkflowBuilderAgent.mock.calls[0][0];
|
||||
const parsedNodeTypes = actualCall.parsedNodeTypes;
|
||||
|
||||
// Should have the merged HttpRequestTool node and keep the separate HttpRequest node
|
||||
const toolNode = parsedNodeTypes.find(
|
||||
(node: INodeTypeDescription) => node.name === 'HttpRequestTool',
|
||||
);
|
||||
const regularNode = parsedNodeTypes.find(
|
||||
(node: INodeTypeDescription) => node.name === 'HttpRequest',
|
||||
);
|
||||
|
||||
expect(toolNode).toBeDefined();
|
||||
expect(toolNode?.displayName).toBe('HTTP Request Tool');
|
||||
expect(toolNode?.properties).toEqual([
|
||||
{
|
||||
name: 'toolProp',
|
||||
type: 'string',
|
||||
displayName: '',
|
||||
default: undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
// The regular node should still exist separately (not merged into tool)
|
||||
expect(regularNode).toBeDefined();
|
||||
expect(regularNode?.displayName).toBe('HTTP Request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
let mockPayload: ChatPayload;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPayload = {
|
||||
message: 'Create a simple workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'test-workflow' },
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('should yield results from agent chat', async () => {
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual({
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Test response' }],
|
||||
});
|
||||
expect(result.done).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass abort signal to agent', async () => {
|
||||
const abortController = new AbortController();
|
||||
const generator = service.chat(mockPayload, mockUser, abortController.signal);
|
||||
|
||||
await generator.next();
|
||||
|
||||
// Verify that the agent's chat method was called with the abort signal
|
||||
const mockAgentInstance = MockedWorkflowBuilderAgent.mock.results[0]
|
||||
.value as WorkflowBuilderAgent;
|
||||
expect(mockAgentInstance.chat).toHaveBeenCalledWith(
|
||||
mockPayload,
|
||||
'test-user-id',
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle deprecated credentials', async () => {
|
||||
const payloadWithDeprecatedCredentials = {
|
||||
...mockPayload,
|
||||
useDeprecatedCredentials: true,
|
||||
};
|
||||
|
||||
const generator = service.chat(payloadWithDeprecatedCredentials, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Verify that the deprecated credentials flow was used
|
||||
expect(mockClient.generateApiProxyCredentials).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
|
||||
it('should create WorkflowBuilderAgent with correct configuration when using client', async () => {
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Verify WorkflowBuilderAgent was called
|
||||
expect(MockedWorkflowBuilderAgent).toHaveBeenCalled();
|
||||
|
||||
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
|
||||
|
||||
// Verify key configuration properties
|
||||
expect(config).toHaveProperty('parsedNodeTypes');
|
||||
expect(config).toHaveProperty('instanceUrl', 'https://n8n.example.com');
|
||||
expect(config).toHaveProperty('onGenerationSuccess');
|
||||
expect(config).toHaveProperty('tracer');
|
||||
expect(config).toHaveProperty('checkpointer', mockMemorySaver);
|
||||
expect(config.parsedNodeTypes).toBeInstanceOf(Array);
|
||||
expect(config.onGenerationSuccess).toBeInstanceOf(Function);
|
||||
// Verify checkpointer comes from SessionManagerService
|
||||
expect(mockSessionManager.getCheckpointer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create WorkflowBuilderAgent without tracer when no client', async () => {
|
||||
const serviceWithoutClient = new AiWorkflowBuilderService(
|
||||
mockNodeTypes,
|
||||
undefined,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const generator = serviceWithoutClient.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Verify WorkflowBuilderAgent was called
|
||||
expect(MockedWorkflowBuilderAgent).toHaveBeenCalled();
|
||||
|
||||
const config =
|
||||
MockedWorkflowBuilderAgent.mock.calls[MockedWorkflowBuilderAgent.mock.calls.length - 1][0];
|
||||
|
||||
// Verify key configuration properties
|
||||
expect(config).toHaveProperty('parsedNodeTypes');
|
||||
expect(config).toHaveProperty('instanceUrl', undefined);
|
||||
expect(config).toHaveProperty('onGenerationSuccess');
|
||||
expect(config).toHaveProperty('tracer', undefined);
|
||||
expect(config).toHaveProperty('checkpointer');
|
||||
expect(config.parsedNodeTypes).toBeInstanceOf(Array);
|
||||
expect(config.onGenerationSuccess).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should throw LLMServiceError when model setup fails', async () => {
|
||||
const testError = new Error('Model setup failed');
|
||||
anthropicClaudeSonnet4Mock.mockRejectedValue(testError);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
|
||||
await expect(generator.next()).rejects.toThrow(LLMServiceError);
|
||||
});
|
||||
|
||||
it('should include error details in LLMServiceError', async () => {
|
||||
const testError = new Error('Specific error message');
|
||||
anthropicClaudeSonnet4Mock.mockRejectedValue(testError);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
|
||||
try {
|
||||
await generator.next();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(LLMServiceError);
|
||||
expect((error as LLMServiceError).message).toContain('Specific error message');
|
||||
expect((error as LLMServiceError).tags).toMatchObject({
|
||||
hasClient: true,
|
||||
hasUser: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should use environment variables when no client provided', async () => {
|
||||
const serviceWithoutClient = new AiWorkflowBuilderService(mockNodeTypes);
|
||||
process.env.N8N_AI_ANTHROPIC_KEY = 'test-env-key';
|
||||
|
||||
const generator = serviceWithoutClient.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
expect(anthropicClaudeSonnet4Mock).toHaveBeenCalledWith({
|
||||
baseUrl: undefined,
|
||||
apiKey: 'test-env-key',
|
||||
headers: {
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
});
|
||||
|
||||
delete process.env.N8N_AI_ANTHROPIC_KEY;
|
||||
});
|
||||
|
||||
it('should call onGenerationSuccess callback when not using deprecated credentials', async () => {
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
|
||||
|
||||
// Call the onGenerationSuccess callback
|
||||
await config.onGenerationSuccess!();
|
||||
|
||||
expect(mockClient.markBuilderSuccess).toHaveBeenCalledWith(mockUser, {
|
||||
Authorization: 'Bearer test-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onCreditsUpdated callback after markBuilderSuccess', async () => {
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
|
||||
|
||||
// Call the onGenerationSuccess callback
|
||||
await config.onGenerationSuccess!();
|
||||
|
||||
// Verify callback was called with correct parameters
|
||||
expect(mockOnCreditsUpdated).toHaveBeenCalledWith('test-user-id', 10, 1);
|
||||
});
|
||||
|
||||
it('should not call markBuilderSuccess when using deprecated credentials', async () => {
|
||||
const payloadWithDeprecatedCredentials = {
|
||||
...mockPayload,
|
||||
useDeprecatedCredentials: true,
|
||||
};
|
||||
|
||||
const generator = service.chat(payloadWithDeprecatedCredentials, mockUser);
|
||||
await generator.next();
|
||||
|
||||
const config = MockedWorkflowBuilderAgent.mock.calls[0][0];
|
||||
|
||||
// Call the onGenerationSuccess callback
|
||||
await config.onGenerationSuccess!();
|
||||
|
||||
// Should not call markBuilderSuccess for deprecated credentials
|
||||
expect(mockClient.markBuilderSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessions', () => {
|
||||
beforeEach(() => {
|
||||
formatMessagesMock.mockReturnValue([
|
||||
{ role: 'user', type: 'message', text: 'Hello' },
|
||||
{ role: 'assistant', type: 'message', text: 'Hi there!' },
|
||||
]);
|
||||
|
||||
// Reset mocks for each test
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockReset();
|
||||
(mockSessionManager.getSessions as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
it('should return empty sessions when no workflowId provided', async () => {
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
|
||||
|
||||
const result = await service.getSessions(undefined, mockUser);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(undefined, 'test-user-id');
|
||||
});
|
||||
|
||||
it('should return session when workflowId exists', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const mockSession = {
|
||||
sessionId: 'workflow-test-workflow-user-test-user-id',
|
||||
messages: [
|
||||
{ role: 'user', type: 'message', text: 'Hello' },
|
||||
{ role: 'assistant', type: 'message', text: 'Hi there!' },
|
||||
],
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
};
|
||||
|
||||
// Mock SessionManagerService to return the session
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
|
||||
sessions: [mockSession],
|
||||
});
|
||||
|
||||
const result = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'workflow-test-workflow-user-test-user-id',
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
});
|
||||
expect(result.sessions[0].messages).toHaveLength(2);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
});
|
||||
|
||||
it('should handle missing checkpoint gracefully', async () => {
|
||||
const workflowId = 'non-existent-workflow';
|
||||
|
||||
// Mock SessionManagerService to return empty sessions
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
|
||||
|
||||
const result = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
});
|
||||
|
||||
it('should handle checkpoint without messages', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const mockSession = {
|
||||
sessionId: 'workflow-test-workflow-user-test-user-id',
|
||||
messages: [],
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
};
|
||||
|
||||
// Mock SessionManagerService to return session with empty messages
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
|
||||
sessions: [mockSession],
|
||||
});
|
||||
|
||||
const result = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
});
|
||||
|
||||
it('should handle checkpoint with null messages', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const mockSession = {
|
||||
sessionId: 'workflow-test-workflow-user-test-user-id',
|
||||
messages: [],
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
};
|
||||
|
||||
// Mock SessionManagerService to return session with empty messages
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
|
||||
sessions: [mockSession],
|
||||
});
|
||||
|
||||
const result = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
});
|
||||
|
||||
it('should work without user', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
|
||||
// Mock SessionManagerService to return empty sessions
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
|
||||
|
||||
const result = await service.getSessions(workflowId);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should handle complete workflow from chat to session retrieval', async () => {
|
||||
const workflowId = 'integration-test-workflow';
|
||||
const mockPayload: ChatPayload = {
|
||||
message: 'Create a workflow with HTTP request',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: workflowId },
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
|
||||
// First, simulate a chat session
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Mock the session to simulate that it was saved
|
||||
const mockSession = {
|
||||
sessionId: 'workflow-integration-test-workflow-user-test-user-id',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Create a workflow with HTTP request' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will create a workflow with an HTTP request node for you.',
|
||||
},
|
||||
],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Mock SessionManagerService to return the session
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({
|
||||
sessions: [mockSession],
|
||||
});
|
||||
|
||||
// Then retrieve the session
|
||||
const sessions = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(sessions.sessions).toHaveLength(1);
|
||||
expect(sessions.sessions[0].sessionId).toBe(
|
||||
'workflow-integration-test-workflow-user-test-user-id',
|
||||
);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuilderInstanceCredits', () => {
|
||||
it('should return builder instance credits when client is available', async () => {
|
||||
const expectedCredits = {
|
||||
creditsQuota: 100,
|
||||
creditsClaimed: 25,
|
||||
};
|
||||
|
||||
(mockClient.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
|
||||
|
||||
const result = await service.getBuilderInstanceCredits(mockUser);
|
||||
|
||||
expect(result).toEqual(expectedCredits);
|
||||
expect(mockClient.getBuilderInstanceCredits).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
|
||||
it('should return default values when client is not configured', async () => {
|
||||
const serviceWithoutClient = new AiWorkflowBuilderService(mockNodeTypes);
|
||||
|
||||
const result = await serviceWithoutClient.getBuilderInstanceCredits(mockUser);
|
||||
|
||||
expect(result).toEqual({
|
||||
creditsQuota: -1,
|
||||
creditsClaimed: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,368 @@
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock, mockClear } from 'jest-mock-extended';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { SessionManagerService } from '../session-manager.service';
|
||||
import { getBuilderToolsForDisplay } from '../tools/builder-tools';
|
||||
import * as streamProcessor from '../utils/stream-processor';
|
||||
|
||||
jest.mock('@langchain/langgraph');
|
||||
jest.mock('../utils/stream-processor');
|
||||
jest.mock('../tools/builder-tools', () => ({
|
||||
getBuilderToolsForDisplay: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
describe('SessionManagerService', () => {
|
||||
let service: SessionManagerService;
|
||||
let mockLogger: ReturnType<typeof mock<Logger>>;
|
||||
let mockMemorySaver: ReturnType<typeof mock<MemorySaver>>;
|
||||
let mockParsedNodeTypes: INodeTypeDescription[];
|
||||
let formatMessagesSpy: jest.SpyInstance;
|
||||
|
||||
const MockedMemorySaver = MemorySaver as jest.MockedClass<typeof MemorySaver>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = mock<Logger>();
|
||||
mockMemorySaver = mock<MemorySaver>();
|
||||
mockParsedNodeTypes = [
|
||||
{
|
||||
displayName: 'HTTP Request',
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Makes HTTP requests',
|
||||
defaults: { name: 'HTTP Request' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
];
|
||||
|
||||
MockedMemorySaver.mockImplementation(() => mockMemorySaver);
|
||||
|
||||
// Mock formatMessages to return a simple formatted array
|
||||
formatMessagesSpy = jest.spyOn(streamProcessor, 'formatMessages').mockImplementation(() => [
|
||||
{ role: 'human', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' },
|
||||
]);
|
||||
|
||||
service = new SessionManagerService(mockParsedNodeTypes, mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockClear(mockLogger);
|
||||
mockClear(mockMemorySaver);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with provided dependencies', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(MockedMemorySaver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without logger', () => {
|
||||
const serviceWithoutLogger = new SessionManagerService(mockParsedNodeTypes);
|
||||
expect(serviceWithoutLogger).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateThreadId', () => {
|
||||
it('should generate thread ID with workflowId and userId', () => {
|
||||
const threadId = SessionManagerService.generateThreadId('workflow-123', 'user-456');
|
||||
expect(threadId).toBe('workflow-workflow-123-user-user-456');
|
||||
});
|
||||
|
||||
it('should generate thread ID with workflowId but without userId', () => {
|
||||
const threadId = SessionManagerService.generateThreadId('workflow-123');
|
||||
expect(threadId).toMatch(/^workflow-workflow-123-user-\d+$/);
|
||||
});
|
||||
|
||||
it('should generate random UUID when no workflowId provided', () => {
|
||||
const threadId = SessionManagerService.generateThreadId();
|
||||
// UUID v4 format check
|
||||
expect(threadId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate random UUID when workflowId is undefined', () => {
|
||||
const threadId = SessionManagerService.generateThreadId(undefined, 'user-123');
|
||||
expect(threadId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckpointer', () => {
|
||||
it('should return the checkpointer instance', () => {
|
||||
const checkpointer = service.getCheckpointer();
|
||||
expect(checkpointer).toBe(mockMemorySaver);
|
||||
});
|
||||
|
||||
it('should always return the same checkpointer instance', () => {
|
||||
const checkpointer1 = service.getCheckpointer();
|
||||
const checkpointer2 = service.getCheckpointer();
|
||||
expect(checkpointer1).toBe(checkpointer2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessions', () => {
|
||||
it('should return empty sessions when no workflowId provided', async () => {
|
||||
const result = await service.getSessions(undefined, 'user-123');
|
||||
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
expect(mockMemorySaver.getTuple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return session when checkpoint exists', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Hello'), new AIMessage('Hi there!')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'workflow-test-workflow-user-test-user',
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
});
|
||||
expect(result.sessions[0].messages).toEqual([
|
||||
{ role: 'human', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' },
|
||||
]);
|
||||
|
||||
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
|
||||
configurable: {
|
||||
thread_id: 'workflow-test-workflow-user-test-user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle checkpoint without messages', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
// Since there are no messages, formatMessages will be called with empty array
|
||||
formatMessagesSpy.mockReturnValue([]);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle checkpoint with null channel_values', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: null,
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
formatMessagesSpy.mockReturnValue([]);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle invalid messages gracefully', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [
|
||||
{ invalid: 'object' }, // Invalid message format
|
||||
'not an object', // Invalid type
|
||||
null, // Null value
|
||||
],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
formatMessagesSpy.mockReturnValue([]);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
expect(formatMessagesSpy).toHaveBeenCalledWith([], expect.anything());
|
||||
});
|
||||
|
||||
it('should handle missing checkpoint gracefully', async () => {
|
||||
const workflowId = 'non-existent-workflow';
|
||||
const userId = 'test-user';
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle checkpoint errors gracefully', async () => {
|
||||
const workflowId = 'error-workflow';
|
||||
const userId = 'test-user';
|
||||
const error = new Error('Failed to get checkpoint');
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('No session found for workflow:', {
|
||||
workflowId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work without userId', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Hello')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await service.getSessions(workflowId, undefined);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].sessionId).toMatch(/^workflow-test-workflow-user-\d+$/);
|
||||
});
|
||||
|
||||
it('should pass correct parameters to formatMessages', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messages = [new HumanMessage('Test'), new AIMessage('Response')];
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages,
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(formatMessagesSpy).toHaveBeenCalledWith(messages, expect.anything());
|
||||
expect(getBuilderToolsForDisplay).toHaveBeenCalledWith({
|
||||
nodeTypes: mockParsedNodeTypes,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ToolMessage in messages', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messages = [
|
||||
new HumanMessage('Test'),
|
||||
new AIMessage({
|
||||
content: 'Let me help',
|
||||
tool_calls: [{ name: 'test_tool', args: {}, id: 'tool-1' }],
|
||||
}),
|
||||
new ToolMessage({ content: 'Tool result', tool_call_id: 'tool-1', name: 'test_tool' }),
|
||||
];
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages,
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
formatMessagesSpy.mockReturnValue([
|
||||
{ role: 'human', content: 'Test' },
|
||||
{ role: 'assistant', content: 'Let me help', tool_calls: [{ name: 'test_tool' }] },
|
||||
{ role: 'tool', content: 'Tool result', name: 'test_tool' },
|
||||
]);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toHaveLength(3);
|
||||
expect(formatMessagesSpy).toHaveBeenCalledWith(messages, expect.anything());
|
||||
});
|
||||
|
||||
it('should handle empty workflowId string', async () => {
|
||||
const result = await service.getSessions('', 'user-123');
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
expect(mockMemorySaver.getTuple).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with other components', () => {
|
||||
it('should use parsed node types from constructor', async () => {
|
||||
const customNodeTypes: INodeTypeDescription[] = [
|
||||
{
|
||||
displayName: 'Custom Node',
|
||||
name: 'custom.node',
|
||||
group: ['organization'],
|
||||
version: 1,
|
||||
description: 'Custom test node',
|
||||
defaults: { name: 'Custom' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
];
|
||||
|
||||
const customService = new SessionManagerService(customNodeTypes, mockLogger);
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Test')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
await customService.getSessions(workflowId, userId);
|
||||
|
||||
// Verify that the custom node types are used
|
||||
expect(formatMessagesSpy).toHaveBeenCalledWith(expect.anything(), expect.anything());
|
||||
expect(getBuilderToolsForDisplay).toHaveBeenCalledWith({
|
||||
nodeTypes: customNodeTypes,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { ToolMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import { GraphRecursionError } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
@ -9,16 +7,6 @@ import { mock } from 'jest-mock-extended';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||
import { ValidationError } from '@/errors';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import { createStreamProcessor, formatMessages } from '@/utils/stream-processor';
|
||||
import {
|
||||
WorkflowBuilderAgent,
|
||||
type WorkflowBuilderAgentConfig,
|
||||
type ChatPayload,
|
||||
} from '@/workflow-builder-agent';
|
||||
|
||||
jest.mock('@/tools/add-node.tool', () => ({
|
||||
createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }),
|
||||
}));
|
||||
@ -39,6 +27,9 @@ jest.mock('@/tools/update-node-parameters.tool', () => ({
|
||||
.fn()
|
||||
.mockReturnValue({ tool: { name: 'update_node_parameters' } }),
|
||||
}));
|
||||
jest.mock('@/tools/get-node-parameter.tool', () => ({
|
||||
createGetNodeParameterTool: jest.fn().mockReturnValue({ tool: { name: 'get_node_parameter' } }),
|
||||
}));
|
||||
jest.mock('@/tools/prompts/main-agent.prompt', () => ({
|
||||
mainAgentPrompt: {
|
||||
invoke: jest.fn().mockResolvedValue('mocked prompt'),
|
||||
@ -70,6 +61,16 @@ Object.defineProperty(global, 'crypto', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||
import { ValidationError } from '@/errors';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import { createStreamProcessor } from '@/utils/stream-processor';
|
||||
import {
|
||||
WorkflowBuilderAgent,
|
||||
type WorkflowBuilderAgentConfig,
|
||||
type ChatPayload,
|
||||
} from '@/workflow-builder-agent';
|
||||
|
||||
describe('WorkflowBuilderAgent', () => {
|
||||
let agent: WorkflowBuilderAgent;
|
||||
let mockLlmSimple: BaseChatModel;
|
||||
@ -82,7 +83,6 @@ describe('WorkflowBuilderAgent', () => {
|
||||
const mockCreateStreamProcessor = createStreamProcessor as jest.MockedFunction<
|
||||
typeof createStreamProcessor
|
||||
>;
|
||||
const mockFormatMessages = formatMessages as jest.MockedFunction<typeof formatMessages>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLlmSimple = mock<BaseChatModel>({
|
||||
@ -134,35 +134,6 @@ describe('WorkflowBuilderAgent', () => {
|
||||
agent = new WorkflowBuilderAgent(config);
|
||||
});
|
||||
|
||||
describe('generateThreadId', () => {
|
||||
beforeEach(() => {
|
||||
mockRandomUUID.mockReset();
|
||||
});
|
||||
|
||||
it('should generate thread ID with workflowId and userId', () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
|
||||
expect(threadId).toBe('workflow-workflow-123-user-user-456');
|
||||
});
|
||||
|
||||
it('should generate thread ID with workflowId but without userId', () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId);
|
||||
expect(threadId).toMatch(/^workflow-workflow-123-user-\d+$/);
|
||||
});
|
||||
|
||||
it('should generate random UUID when no workflowId provided', () => {
|
||||
const mockUuid = 'test-uuid-1234-5678-9012';
|
||||
mockRandomUUID.mockReturnValue(mockUuid);
|
||||
|
||||
const threadId = WorkflowBuilderAgent.generateThreadId();
|
||||
|
||||
expect(mockRandomUUID).toHaveBeenCalled();
|
||||
expect(threadId).toBe(mockUuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat method', () => {
|
||||
let mockPayload: ChatPayload;
|
||||
|
||||
@ -172,6 +143,7 @@ describe('WorkflowBuilderAgent', () => {
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'workflow-123' },
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
});
|
||||
|
||||
@ -179,6 +151,7 @@ describe('WorkflowBuilderAgent', () => {
|
||||
const longMessage = 'x'.repeat(MAX_AI_BUILDER_PROMPT_LENGTH + 1);
|
||||
const payload: ChatPayload = {
|
||||
message: longMessage,
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
|
||||
await expect(async () => {
|
||||
@ -196,6 +169,7 @@ describe('WorkflowBuilderAgent', () => {
|
||||
const validMessage = 'Create a simple workflow';
|
||||
const payload: ChatPayload = {
|
||||
message: validMessage,
|
||||
useDeprecatedCredentials: false,
|
||||
};
|
||||
|
||||
// Mock the stream processing to return a proper StreamOutput
|
||||
@ -275,94 +249,4 @@ describe('WorkflowBuilderAgent', () => {
|
||||
}).rejects.toThrow(unknownError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessions', () => {
|
||||
beforeEach(() => {
|
||||
mockFormatMessages.mockImplementation(
|
||||
(messages: Array<AIMessage | HumanMessage | ToolMessage>) =>
|
||||
messages.map((m) => ({ type: m.constructor.name.toLowerCase(), content: m.content })),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return session for existing workflowId', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Hello'), new AIMessage('Hi there!')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await agent.getSessions(workflowId, userId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'workflow-workflow-123-user-user-456',
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
});
|
||||
expect(result.sessions[0].messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty sessions when workflowId is undefined', async () => {
|
||||
const result = await agent.getSessions(undefined);
|
||||
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
expect(mockCheckpointer.getTuple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty sessions when no checkpoint exists', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
(mockCheckpointer.getTuple as jest.Mock).mockRejectedValue(new Error('Thread not found'));
|
||||
|
||||
const result = await agent.getSessions(workflowId);
|
||||
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('No session found for workflow:', {
|
||||
workflowId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
error: expect.any(Error),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle checkpoint without messages', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await agent.getSessions(workflowId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle checkpoint with null messages', async () => {
|
||||
const workflowId = 'workflow-123';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: null,
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockCheckpointer.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await agent.getSessions(workflowId);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
|
||||
import type { INode, INodeParameters, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { NodeTypeNotFoundError, ValidationError } from '../errors';
|
||||
import { createNodeInstance, generateUniqueName } from './utils/node-creation.utils';
|
||||
import { calculateNodePosition } from './utils/node-positioning.utils';
|
||||
@ -13,8 +15,6 @@ import { findNodeType } from './helpers/validation';
|
||||
import type { AddedNode } from '../types/nodes';
|
||||
import type { AddNodeOutput, ToolError } from '../types/tools';
|
||||
|
||||
const DISPLAY_TITLE = 'Adding nodes';
|
||||
|
||||
/**
|
||||
* Schema for node creation input
|
||||
*/
|
||||
@ -81,16 +81,25 @@ function getCustomNodeTitle(
|
||||
return 'Adding node';
|
||||
}
|
||||
|
||||
export function getAddNodeToolBase(nodeTypes: INodeTypeDescription[]): BuilderToolBase {
|
||||
return {
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Adding nodes',
|
||||
getCustomDisplayTitle: (input: Record<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create the add node tool
|
||||
*/
|
||||
export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) {
|
||||
export function createAddNodeTool(nodeTypes: INodeTypeDescription[]): BuilderTool {
|
||||
const builderToolBase = getAddNodeToolBase(nodeTypes);
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
'add_nodes',
|
||||
DISPLAY_TITLE,
|
||||
builderToolBase.toolName,
|
||||
builderToolBase.displayTitle,
|
||||
getCustomNodeTitle(input, nodeTypes),
|
||||
);
|
||||
|
||||
@ -181,7 +190,7 @@ export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'add_nodes',
|
||||
name: builderToolBase.toolName,
|
||||
description: `Add a node to the workflow canvas. Each node represents a specific action or operation (e.g., HTTP request, data transformation, database query). Always provide descriptive names that explain what the node does (e.g., "Get Customer Data", "Filter Active Users", "Send Email Notification"). The tool handles automatic positioning. Use this tool after searching for available node types to ensure they exist.
|
||||
|
||||
To add multiple nodes, call this tool multiple times in parallel.
|
||||
@ -219,7 +228,6 @@ Think through the connectionParametersReasoning FIRST, then set connectionParame
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
getCustomDisplayTitle: (input: Record<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
|
||||
...builderToolBase,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { createAddNodeTool, getAddNodeToolBase } from './add-node.tool';
|
||||
import { CONNECT_NODES_TOOL, createConnectNodesTool } from './connect-nodes.tool';
|
||||
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from './get-node-parameter.tool';
|
||||
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from './node-details.tool';
|
||||
import { createNodeSearchTool, NODE_SEARCH_TOOL } from './node-search.tool';
|
||||
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from './remove-node.tool';
|
||||
import {
|
||||
createUpdateNodeParametersTool,
|
||||
UPDATING_NODE_PARAMETER_TOOL,
|
||||
} from './update-node-parameters.tool';
|
||||
|
||||
export function getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
logger,
|
||||
llmComplexTask,
|
||||
instanceUrl,
|
||||
}: {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llmComplexTask: BaseChatModel;
|
||||
logger?: Logger;
|
||||
instanceUrl?: string;
|
||||
}): BuilderTool[] {
|
||||
return [
|
||||
createNodeSearchTool(parsedNodeTypes),
|
||||
createNodeDetailsTool(parsedNodeTypes),
|
||||
createAddNodeTool(parsedNodeTypes),
|
||||
createConnectNodesTool(parsedNodeTypes, logger),
|
||||
createRemoveNodeTool(logger),
|
||||
createUpdateNodeParametersTool(parsedNodeTypes, llmComplexTask, logger, instanceUrl),
|
||||
createGetNodeParameterTool(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return display information for tools
|
||||
* Without the actual LangChain implementation
|
||||
* Used when loading previous sessions for example
|
||||
*/
|
||||
export function getBuilderToolsForDisplay({
|
||||
nodeTypes,
|
||||
}: { nodeTypes: INodeTypeDescription[] }): BuilderToolBase[] {
|
||||
return [
|
||||
NODE_SEARCH_TOOL,
|
||||
NODE_DETAILS_TOOL,
|
||||
getAddNodeToolBase(nodeTypes),
|
||||
CONNECT_NODES_TOOL,
|
||||
REMOVE_NODE_TOOL,
|
||||
UPDATING_NODE_PARAMETER_TOOL,
|
||||
GET_NODE_PARAMETER_TOOL,
|
||||
];
|
||||
}
|
||||
@ -3,6 +3,8 @@ import type { Logger } from '@n8n/backend-common';
|
||||
import { type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import {
|
||||
ConnectionError,
|
||||
NodeNotFoundError,
|
||||
@ -45,16 +47,26 @@ export const nodeConnectionSchema = z.object({
|
||||
.describe('The index of the input to connect to (default: 0)'),
|
||||
});
|
||||
|
||||
export const CONNECT_NODES_TOOL: BuilderToolBase = {
|
||||
toolName: 'connect_nodes',
|
||||
displayTitle: 'Connecting nodes',
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the connect nodes tool
|
||||
*/
|
||||
export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger?: Logger) {
|
||||
const DISPLAY_TITLE = 'Connecting nodes';
|
||||
|
||||
export function createConnectNodesTool(
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
logger?: Logger,
|
||||
): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
// eslint-disable-next-line complexity
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'connect_nodes', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
CONNECT_NODES_TOOL.toolName,
|
||||
CONNECT_NODES_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -290,7 +302,7 @@ export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'connect_nodes',
|
||||
name: CONNECT_NODES_TOOL.toolName,
|
||||
description: `Connect two nodes in the workflow. The tool automatically determines the connection type based on node capabilities and ensures correct connection direction.
|
||||
|
||||
UNDERSTANDING CONNECTIONS:
|
||||
@ -321,6 +333,6 @@ CONNECTION EXAMPLES:
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...CONNECT_NODES_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
getCurrentWorkflow,
|
||||
getWorkflowState,
|
||||
} from '@/tools/helpers';
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
@ -17,6 +18,8 @@ import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { validateNodeExists, createNodeNotFoundError } from './helpers/validation';
|
||||
import type { GetNodeParameterOutput } from '../types/tools';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting node parameter';
|
||||
|
||||
/**
|
||||
* Schema for getting specific node parameter
|
||||
*/
|
||||
@ -54,15 +57,22 @@ function formatNodeParameter(path: string, value: NodeParameterValueType): strin
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_NODE_PARAMETER_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_node_parameter',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get node parameter tool
|
||||
*/
|
||||
export function createGetNodeParameterTool(logger?: Logger) {
|
||||
const DISPLAY_TITLE = 'Getting node parameter';
|
||||
|
||||
export function createGetNodeParameterTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(config, 'get_node_parameter', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_NODE_PARAMETER_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -134,7 +144,7 @@ export function createGetNodeParameterTool(logger?: Logger) {
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{
|
||||
toolName: 'get_node_parameter',
|
||||
toolName: GET_NODE_PARAMETER_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -143,7 +153,7 @@ export function createGetNodeParameterTool(logger?: Logger) {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_node_parameter',
|
||||
name: GET_NODE_PARAMETER_TOOL.toolName,
|
||||
description:
|
||||
'Get the value of a specific parameter of a specific node. Use this ONLY to retrieve parameters omitted in the workflow JSON context because of the size.',
|
||||
schema: getNodeParameterSchema,
|
||||
@ -152,6 +162,6 @@ export function createGetNodeParameterTool(logger?: Logger) {
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...GET_NODE_PARAMETER_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
@ -122,15 +124,22 @@ function extractNodeDetails(nodeType: INodeTypeDescription): NodeDetails {
|
||||
};
|
||||
}
|
||||
|
||||
export const NODE_DETAILS_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_node_details',
|
||||
displayTitle: 'Getting node details',
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the node details tool
|
||||
*/
|
||||
export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
const DISPLAY_TITLE = 'Getting node details';
|
||||
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(config, 'get_node_details', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
NODE_DETAILS_TOOL.toolName,
|
||||
NODE_DETAILS_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -181,7 +190,7 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{
|
||||
toolName: 'get_node_details',
|
||||
toolName: NODE_DETAILS_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -190,7 +199,7 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_node_details',
|
||||
name: NODE_DETAILS_TOOL.toolName,
|
||||
description:
|
||||
'Get detailed information about a specific n8n node type including properties and available connections. Use this before adding nodes to understand their input/output structure.',
|
||||
schema: nodeDetailsSchema,
|
||||
@ -199,6 +208,6 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...NODE_DETAILS_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import { tool } from '@langchain/core/tools';
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import { NodeSearchEngine } from './engines/node-search-engine';
|
||||
import { createProgressReporter, createBatchProgressReporter } from './helpers/progress';
|
||||
@ -108,15 +110,22 @@ function buildResponseMessage(
|
||||
return responseContent;
|
||||
}
|
||||
|
||||
export const NODE_SEARCH_TOOL: BuilderToolBase = {
|
||||
toolName: 'search_nodes',
|
||||
displayTitle: 'Searching nodes',
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the node search tool
|
||||
*/
|
||||
export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
|
||||
const DISPLAY_TITLE = 'Searching nodes';
|
||||
|
||||
const dynamicTool = tool(
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'search_nodes', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
NODE_SEARCH_TOOL.toolName,
|
||||
NODE_SEARCH_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -178,7 +187,7 @@ export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{
|
||||
toolName: 'search_nodes',
|
||||
toolName: NODE_SEARCH_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -187,7 +196,7 @@ export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
name: NODE_SEARCH_TOOL.toolName,
|
||||
description: `Search for n8n nodes by name or find sub-nodes that output specific connection types. Use this before adding nodes to find the correct node types.
|
||||
|
||||
Search modes:
|
||||
@ -215,6 +224,6 @@ You can search for multiple different criteria at once by providing an array of
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...NODE_SEARCH_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ import type { Logger } from '@n8n/backend-common';
|
||||
import type { IConnections } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
@ -69,15 +71,22 @@ function buildResponseMessage(
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const REMOVE_NODE_TOOL: BuilderToolBase = {
|
||||
toolName: 'remove_node',
|
||||
displayTitle: 'Removing node',
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the remove node tool
|
||||
*/
|
||||
export function createRemoveNodeTool(_logger?: Logger) {
|
||||
const DISPLAY_TITLE = 'Removing node';
|
||||
|
||||
export function createRemoveNodeTool(_logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'remove_node', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
REMOVE_NODE_TOOL.toolName,
|
||||
REMOVE_NODE_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -139,7 +148,7 @@ export function createRemoveNodeTool(_logger?: Logger) {
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{
|
||||
toolName: 'remove_node',
|
||||
toolName: REMOVE_NODE_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -148,7 +157,7 @@ export function createRemoveNodeTool(_logger?: Logger) {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remove_node',
|
||||
name: REMOVE_NODE_TOOL.toolName,
|
||||
description:
|
||||
'Remove a node from the workflow by its ID. This will also remove all connections to and from the node. Use this tool when you need to delete a node that is no longer needed in the workflow.',
|
||||
schema: removeNodeSchema,
|
||||
@ -157,6 +166,6 @@ export function createRemoveNodeTool(_logger?: Logger) {
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...REMOVE_NODE_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { createNodeType, nodeTypes } from '../../../test/test-utils';
|
||||
import { createAddNodeTool, getAddNodeToolBase } from '../add-node.tool';
|
||||
import { getBuilderTools, getBuilderToolsForDisplay } from '../builder-tools';
|
||||
import { CONNECT_NODES_TOOL, createConnectNodesTool } from '../connect-nodes.tool';
|
||||
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from '../get-node-parameter.tool';
|
||||
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from '../node-details.tool';
|
||||
import { createNodeSearchTool, NODE_SEARCH_TOOL } from '../node-search.tool';
|
||||
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from '../remove-node.tool';
|
||||
import {
|
||||
createUpdateNodeParametersTool,
|
||||
UPDATING_NODE_PARAMETER_TOOL,
|
||||
} from '../update-node-parameters.tool';
|
||||
|
||||
jest.mock('../add-node.tool', () => ({
|
||||
createAddNodeTool: jest.fn().mockReturnValue({
|
||||
name: 'addNodeTool',
|
||||
tool: { name: 'addNodeTool' },
|
||||
}),
|
||||
getAddNodeToolBase: jest.fn().mockReturnValue({
|
||||
name: 'addNodeTool',
|
||||
description: 'Add a node to the workflow',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../connect-nodes.tool', () => ({
|
||||
CONNECT_NODES_TOOL: {
|
||||
name: 'connectNodesTool',
|
||||
description: 'Connect two nodes',
|
||||
},
|
||||
createConnectNodesTool: jest.fn().mockReturnValue({
|
||||
name: 'connectNodesTool',
|
||||
tool: { name: 'connectNodesTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../get-node-parameter.tool', () => ({
|
||||
GET_NODE_PARAMETER_TOOL: {
|
||||
name: 'getNodeParameterTool',
|
||||
description: 'Get node parameters',
|
||||
},
|
||||
createGetNodeParameterTool: jest.fn().mockReturnValue({
|
||||
name: 'getNodeParameterTool',
|
||||
tool: { name: 'getNodeParameterTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../node-details.tool', () => ({
|
||||
NODE_DETAILS_TOOL: {
|
||||
name: 'nodeDetailsTool',
|
||||
description: 'Get node details',
|
||||
},
|
||||
createNodeDetailsTool: jest.fn().mockReturnValue({
|
||||
name: 'nodeDetailsTool',
|
||||
tool: { name: 'nodeDetailsTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../node-search.tool', () => ({
|
||||
NODE_SEARCH_TOOL: {
|
||||
name: 'nodeSearchTool',
|
||||
description: 'Search for nodes',
|
||||
},
|
||||
createNodeSearchTool: jest.fn().mockReturnValue({
|
||||
name: 'nodeSearchTool',
|
||||
tool: { name: 'nodeSearchTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../remove-node.tool', () => ({
|
||||
REMOVE_NODE_TOOL: {
|
||||
name: 'removeNodeTool',
|
||||
description: 'Remove a node',
|
||||
},
|
||||
createRemoveNodeTool: jest.fn().mockReturnValue({
|
||||
name: 'removeNodeTool',
|
||||
tool: { name: 'removeNodeTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../update-node-parameters.tool', () => ({
|
||||
UPDATING_NODE_PARAMETER_TOOL: {
|
||||
name: 'updateNodeParametersTool',
|
||||
description: 'Update node parameters',
|
||||
},
|
||||
createUpdateNodeParametersTool: jest.fn().mockReturnValue({
|
||||
name: 'updateNodeParametersTool',
|
||||
tool: { name: 'updateNodeParametersTool' },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('builder-tools', () => {
|
||||
let mockLogger: Logger;
|
||||
let mockLlmComplexTask: BaseChatModel;
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLogger = mock<Logger>();
|
||||
mockLlmComplexTask = mock<BaseChatModel>();
|
||||
parsedNodeTypes = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook];
|
||||
});
|
||||
|
||||
describe('getBuilderTools', () => {
|
||||
it('should return all builder tools in the correct order', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
logger: mockLogger,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
instanceUrl: 'https://test.n8n.io',
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(7);
|
||||
expect(createNodeSearchTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createAddNodeTool).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
|
||||
expect(createRemoveNodeTool).toHaveBeenCalledWith(mockLogger);
|
||||
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
|
||||
parsedNodeTypes,
|
||||
mockLlmComplexTask,
|
||||
mockLogger,
|
||||
'https://test.n8n.io',
|
||||
);
|
||||
expect(createGetNodeParameterTool).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without optional parameters', () => {
|
||||
const tools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(7);
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, undefined);
|
||||
expect(createRemoveNodeTool).toHaveBeenCalledWith(undefined);
|
||||
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
|
||||
parsedNodeTypes,
|
||||
mockLlmComplexTask,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass through different node types', () => {
|
||||
const customNodeTypes = [
|
||||
createNodeType({ name: 'custom.node1' }),
|
||||
createNodeType({ name: 'custom.node2' }),
|
||||
];
|
||||
|
||||
getBuilderTools({
|
||||
parsedNodeTypes: customNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
});
|
||||
|
||||
expect(createNodeSearchTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createNodeDetailsTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createAddNodeTool).toHaveBeenCalledWith(customNodeTypes);
|
||||
expect(createConnectNodesTool).toHaveBeenCalledWith(customNodeTypes, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuilderToolsForDisplay', () => {
|
||||
it('should return all display tools in the correct order', () => {
|
||||
const tools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(7);
|
||||
expect(tools[0]).toBe(NODE_SEARCH_TOOL);
|
||||
expect(tools[1]).toBe(NODE_DETAILS_TOOL);
|
||||
expect(tools[3]).toBe(CONNECT_NODES_TOOL);
|
||||
expect(tools[4]).toBe(REMOVE_NODE_TOOL);
|
||||
expect(tools[5]).toBe(UPDATING_NODE_PARAMETER_TOOL);
|
||||
expect(tools[6]).toBe(GET_NODE_PARAMETER_TOOL);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
});
|
||||
|
||||
it('should work with empty node types array', () => {
|
||||
const tools = getBuilderToolsForDisplay({
|
||||
nodeTypes: [],
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(7);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should work with different node types', () => {
|
||||
const customNodeTypes = [
|
||||
createNodeType({ name: 'custom.display.node1' }),
|
||||
createNodeType({ name: 'custom.display.node2' }),
|
||||
];
|
||||
|
||||
getBuilderToolsForDisplay({
|
||||
nodeTypes: customNodeTypes,
|
||||
});
|
||||
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(customNodeTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistency between getBuilderTools and getBuilderToolsForDisplay', () => {
|
||||
it('should return the same number of tools', () => {
|
||||
const builderTools = getBuilderTools({
|
||||
parsedNodeTypes,
|
||||
llmComplexTask: mockLlmComplexTask,
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
const displayTools = getBuilderToolsForDisplay({
|
||||
nodeTypes: parsedNodeTypes,
|
||||
});
|
||||
|
||||
expect(builderTools).toHaveLength(displayTools.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { tool } from '@langchain/core/tools';
|
||||
import type { INode, INodeTypeDescription, INodeParameters, Logger } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
import { trimWorkflowJSON } from '@/utils/trim-workflow-context';
|
||||
|
||||
import { createParameterUpdaterChain } from '../chains/parameter-updater';
|
||||
@ -24,8 +25,6 @@ import {
|
||||
fixExpressionPrefixes,
|
||||
} from './utils/parameter-update.utils';
|
||||
|
||||
const DISPLAY_TITLE = 'Updating node parameters';
|
||||
|
||||
/**
|
||||
* Schema for update node parameters input
|
||||
*/
|
||||
@ -115,6 +114,11 @@ async function processParameterUpdates(
|
||||
return fixExpressionPrefixes(newParameters.parameters) as INodeParameters;
|
||||
}
|
||||
|
||||
export const UPDATING_NODE_PARAMETER_TOOL: BuilderToolBase = {
|
||||
toolName: 'update_node_parameters',
|
||||
displayTitle: 'Updating node parameters',
|
||||
};
|
||||
|
||||
function getCustomNodeTitle(input: Record<string, unknown>, nodes?: INode[]): string {
|
||||
if ('nodeId' in input && typeof input['nodeId'] === 'string') {
|
||||
const targetNode = nodes?.find((node) => node.id === input.nodeId);
|
||||
@ -123,7 +127,7 @@ function getCustomNodeTitle(input: Record<string, unknown>, nodes?: INode[]): st
|
||||
}
|
||||
}
|
||||
|
||||
return DISPLAY_TITLE;
|
||||
return UPDATING_NODE_PARAMETER_TOOL.displayTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -134,10 +138,14 @@ export function createUpdateNodeParametersTool(
|
||||
llm: BaseChatModel,
|
||||
logger?: Logger,
|
||||
instanceUrl?: string,
|
||||
) {
|
||||
): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(config, 'update_node_parameters', DISPLAY_TITLE);
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
UPDATING_NODE_PARAMETER_TOOL.toolName,
|
||||
UPDATING_NODE_PARAMETER_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@ -215,7 +223,7 @@ export function createUpdateNodeParametersTool(
|
||||
const toolError = new ToolExecutionError(
|
||||
`Failed to update node parameters: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
toolName: 'update_node_parameters',
|
||||
toolName: UPDATING_NODE_PARAMETER_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -235,7 +243,7 @@ export function createUpdateNodeParametersTool(
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
{
|
||||
toolName: 'update_node_parameters',
|
||||
toolName: UPDATING_NODE_PARAMETER_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
@ -244,7 +252,7 @@ export function createUpdateNodeParametersTool(
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'update_node_parameters',
|
||||
name: UPDATING_NODE_PARAMETER_TOOL.toolName,
|
||||
description:
|
||||
'Update the parameters of an existing node in the workflow based on natural language changes. This tool intelligently modifies only the specified parameters while preserving others. Examples: "Set the URL to https://api.example.com", "Add authentication header", "Change method to POST", "Set the condition to check if status equals success".',
|
||||
schema: updateNodeParametersSchema,
|
||||
@ -253,6 +261,6 @@ export function createUpdateNodeParametersTool(
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
...UPDATING_NODE_PARAMETER_TOOL,
|
||||
};
|
||||
}
|
||||
|
||||
55
packages/@n8n/ai-workflow-builder.ee/src/types/sessions.ts
Normal file
55
packages/@n8n/ai-workflow-builder.ee/src/types/sessions.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export type LangchainMessage = AIMessage | HumanMessage | ToolMessage;
|
||||
|
||||
/**
|
||||
* Type guard to validate if a value is a valid Langchain message
|
||||
*/
|
||||
function isLangchainMessage(value: unknown): value is LangchainMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required properties that all message types have
|
||||
if (!('content' in value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = value.content;
|
||||
if (typeof content !== 'string' && !Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for message type indicators
|
||||
const hasValidType =
|
||||
'_getType' in value || // Common method in Langchain messages
|
||||
('constructor' in value &&
|
||||
value.constructor !== null &&
|
||||
typeof value.constructor === 'function' &&
|
||||
'name' in value.constructor &&
|
||||
(value.constructor.name === 'AIMessage' ||
|
||||
value.constructor.name === 'HumanMessage' ||
|
||||
value.constructor.name === 'ToolMessage')) ||
|
||||
('role' in value &&
|
||||
typeof value.role === 'string' &&
|
||||
['assistant', 'human', 'user', 'tool'].includes(value.role));
|
||||
|
||||
return hasValidType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate if a value is an array of Langchain messages
|
||||
*/
|
||||
export function isLangchainMessagesArray(value: unknown): value is LangchainMessage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.every(isLangchainMessage);
|
||||
}
|
||||
@ -9,12 +9,16 @@ import type {
|
||||
StreamOutput,
|
||||
} from '../types/streaming';
|
||||
|
||||
export interface BuilderTool {
|
||||
tool: DynamicStructuredTool;
|
||||
export interface BuilderToolBase {
|
||||
toolName: string;
|
||||
displayTitle: string;
|
||||
getCustomDisplayTitle?: (values: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
export interface BuilderTool extends BuilderToolBase {
|
||||
tool: DynamicStructuredTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools which should trigger canvas updates
|
||||
*/
|
||||
@ -195,7 +199,7 @@ function processAIMessageContent(msg: AIMessage): Array<Record<string, unknown>>
|
||||
*/
|
||||
function createToolCallMessage(
|
||||
toolCall: ToolCall,
|
||||
builderTool?: BuilderTool,
|
||||
builderTool?: BuilderToolBase,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
@ -220,10 +224,10 @@ function createToolCallMessage(
|
||||
*/
|
||||
function processToolCalls(
|
||||
toolCalls: ToolCall[],
|
||||
builderTools?: BuilderTool[],
|
||||
builderTools?: BuilderToolBase[],
|
||||
): Array<Record<string, unknown>> {
|
||||
return toolCalls.map((toolCall) => {
|
||||
const builderTool = builderTools?.find((bt) => bt.tool.name === toolCall.name);
|
||||
const builderTool = builderTools?.find((bt) => bt.toolName === toolCall.name);
|
||||
return createToolCallMessage(toolCall, builderTool);
|
||||
});
|
||||
}
|
||||
@ -254,7 +258,7 @@ function processToolMessage(
|
||||
|
||||
export function formatMessages(
|
||||
messages: Array<AIMessage | HumanMessage | ToolMessage>,
|
||||
builderTools?: BuilderTool[],
|
||||
builderTools?: BuilderToolBase[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const formattedMessages: Array<Record<string, unknown>> = [];
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
|
||||
import type {
|
||||
AgentMessageChunk,
|
||||
@ -7,6 +6,7 @@ import type {
|
||||
WorkflowUpdateChunk,
|
||||
StreamOutput,
|
||||
} from '../../types/streaming';
|
||||
import type { BuilderToolBase } from '../stream-processor';
|
||||
import { processStreamChunk, createStreamProcessor, formatMessages } from '../stream-processor';
|
||||
|
||||
describe('stream-processor', () => {
|
||||
@ -557,13 +557,13 @@ describe('stream-processor', () => {
|
||||
});
|
||||
|
||||
it('should use builder tool display titles', () => {
|
||||
const builderTools = [
|
||||
const builderTools: BuilderToolBase[] = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Add Node',
|
||||
},
|
||||
{
|
||||
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
|
||||
toolName: 'connect_nodes',
|
||||
displayTitle: 'Connect Nodes',
|
||||
},
|
||||
];
|
||||
@ -601,9 +601,9 @@ describe('stream-processor', () => {
|
||||
});
|
||||
|
||||
it('should use custom display titles from builder tools', () => {
|
||||
const builderTools = [
|
||||
const builderTools: BuilderToolBase[] = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Add Node',
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) =>
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
@ -645,9 +645,9 @@ describe('stream-processor', () => {
|
||||
});
|
||||
|
||||
it('should handle custom display title when args is null/undefined', () => {
|
||||
const builderTools = [
|
||||
const builderTools: BuilderToolBase[] = [
|
||||
{
|
||||
tool: { name: 'clear_workflow' } as DynamicStructuredTool,
|
||||
toolName: 'clear_workflow',
|
||||
displayTitle: 'Clear Workflow',
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) =>
|
||||
`Custom: ${Object.keys(values).length} args`,
|
||||
@ -824,15 +824,15 @@ describe('stream-processor', () => {
|
||||
});
|
||||
|
||||
it('should handle complex scenario with multiple message types and builder tools', () => {
|
||||
const builderTools = [
|
||||
const builderTools: BuilderToolBase[] = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Add Node',
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) => `Add ${values.nodeType} Node`,
|
||||
},
|
||||
{
|
||||
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
|
||||
toolName: 'connect_nodes',
|
||||
displayTitle: 'Connect Nodes',
|
||||
},
|
||||
];
|
||||
|
||||
@ -3,7 +3,8 @@ import type { ToolMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
||||
import { StateGraph, MemorySaver, END, GraphRecursionError } from '@langchain/langgraph';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import { StateGraph, END, GraphRecursionError } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
ApplicationError,
|
||||
@ -18,22 +19,17 @@ import {
|
||||
MAX_AI_BUILDER_PROMPT_LENGTH,
|
||||
MAX_INPUT_TOKENS,
|
||||
} from '@/constants';
|
||||
import { createGetNodeParameterTool } from '@/tools/get-node-parameter.tool';
|
||||
import { trimWorkflowJSON } from '@/utils/trim-workflow-context';
|
||||
|
||||
import { conversationCompactChain } from './chains/conversation-compact';
|
||||
import { workflowNameChain } from './chains/workflow-name';
|
||||
import { LLMServiceError, ValidationError, WorkflowStateError } from './errors';
|
||||
import { createAddNodeTool } from './tools/add-node.tool';
|
||||
import { createConnectNodesTool } from './tools/connect-nodes.tool';
|
||||
import { createNodeDetailsTool } from './tools/node-details.tool';
|
||||
import { createNodeSearchTool } from './tools/node-search.tool';
|
||||
import { SessionManagerService } from './session-manager.service';
|
||||
import { getBuilderTools } from './tools/builder-tools';
|
||||
import { mainAgentPrompt } from './tools/prompts/main-agent.prompt';
|
||||
import { createRemoveNodeTool } from './tools/remove-node.tool';
|
||||
import { createUpdateNodeParametersTool } from './tools/update-node-parameters.tool';
|
||||
import type { SimpleWorkflow } from './types/workflow';
|
||||
import { processOperations } from './utils/operations-processor';
|
||||
import { createStreamProcessor, formatMessages, type BuilderTool } from './utils/stream-processor';
|
||||
import { createStreamProcessor, type BuilderTool } from './utils/stream-processor';
|
||||
import { estimateTokenCountFromMessages, extractLastTokenUsage } from './utils/token-usage';
|
||||
import { executeToolsInParallel } from './utils/tool-executor';
|
||||
import { WorkflowState } from './workflow-state';
|
||||
@ -43,10 +39,11 @@ export interface WorkflowBuilderAgentConfig {
|
||||
llmSimpleTask: BaseChatModel;
|
||||
llmComplexTask: BaseChatModel;
|
||||
logger?: Logger;
|
||||
checkpointer?: MemorySaver;
|
||||
checkpointer: MemorySaver;
|
||||
tracer?: LangChainTracer;
|
||||
autoCompactThresholdTokens?: number;
|
||||
instanceUrl?: string;
|
||||
onGenerationSuccess?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChatPayload {
|
||||
@ -56,6 +53,12 @@ export interface ChatPayload {
|
||||
currentWorkflow?: Partial<IWorkflowBase>;
|
||||
executionData?: IRunExecutionData['resultData'];
|
||||
};
|
||||
/**
|
||||
* Calls AI Assistant Service using deprecated credentials and endpoints
|
||||
* These credentials/endpoints will soon be removed
|
||||
* As new implementation is rolled out and builder experiment is released
|
||||
*/
|
||||
useDeprecatedCredentials?: boolean;
|
||||
}
|
||||
|
||||
export class WorkflowBuilderAgent {
|
||||
@ -67,34 +70,28 @@ export class WorkflowBuilderAgent {
|
||||
private tracer?: LangChainTracer;
|
||||
private autoCompactThresholdTokens: number;
|
||||
private instanceUrl?: string;
|
||||
private onGenerationSuccess?: () => Promise<void>;
|
||||
|
||||
constructor(config: WorkflowBuilderAgentConfig) {
|
||||
this.parsedNodeTypes = config.parsedNodeTypes;
|
||||
this.llmSimpleTask = config.llmSimpleTask;
|
||||
this.llmComplexTask = config.llmComplexTask;
|
||||
this.logger = config.logger;
|
||||
this.checkpointer = config.checkpointer ?? new MemorySaver();
|
||||
this.checkpointer = config.checkpointer;
|
||||
this.tracer = config.tracer;
|
||||
this.autoCompactThresholdTokens =
|
||||
config.autoCompactThresholdTokens ?? DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS;
|
||||
this.instanceUrl = config.instanceUrl;
|
||||
this.onGenerationSuccess = config.onGenerationSuccess;
|
||||
}
|
||||
|
||||
private getBuilderTools(): BuilderTool[] {
|
||||
return [
|
||||
createNodeSearchTool(this.parsedNodeTypes),
|
||||
createNodeDetailsTool(this.parsedNodeTypes),
|
||||
createAddNodeTool(this.parsedNodeTypes),
|
||||
createConnectNodesTool(this.parsedNodeTypes, this.logger),
|
||||
createRemoveNodeTool(this.logger),
|
||||
createUpdateNodeParametersTool(
|
||||
this.parsedNodeTypes,
|
||||
this.llmComplexTask,
|
||||
this.logger,
|
||||
this.instanceUrl,
|
||||
),
|
||||
createGetNodeParameterTool(),
|
||||
];
|
||||
return getBuilderTools({
|
||||
parsedNodeTypes: this.parsedNodeTypes,
|
||||
instanceUrl: this.instanceUrl,
|
||||
llmComplexTask: this.llmComplexTask,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
private createWorkflow() {
|
||||
@ -187,6 +184,13 @@ export class WorkflowBuilderAgent {
|
||||
if (lastMessage.tool_calls?.length) {
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
// Call success callback when agent finishes without tool calls (successful generation)
|
||||
if (this.onGenerationSuccess) {
|
||||
void Promise.resolve(this.onGenerationSuccess()).catch((error) => {
|
||||
this.logger?.warn('Failed to execute onGenerationSuccess callback', { error });
|
||||
});
|
||||
}
|
||||
return END;
|
||||
};
|
||||
|
||||
@ -313,12 +317,6 @@ export class WorkflowBuilderAgent {
|
||||
});
|
||||
}
|
||||
|
||||
static generateThreadId(workflowId?: string, userId?: string) {
|
||||
return workflowId
|
||||
? `workflow-${workflowId}-user-${userId ?? new Date().getTime()}`
|
||||
: crypto.randomUUID();
|
||||
}
|
||||
|
||||
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
|
||||
return (
|
||||
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
|
||||
@ -363,7 +361,7 @@ export class WorkflowBuilderAgent {
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
// Generate thread ID from workflowId and userId
|
||||
// This ensures one session per workflow per user
|
||||
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
const threadConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
@ -482,43 +480,4 @@ export class WorkflowBuilderAgent {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getSessions(workflowId: string | undefined, userId?: string) {
|
||||
// For now, we'll return the current session if we have a workflowId
|
||||
// MemorySaver doesn't expose a way to list all threads, so we'll need to
|
||||
// track this differently if we want to list all sessions
|
||||
const sessions = [];
|
||||
|
||||
if (workflowId) {
|
||||
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
|
||||
const threadConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to get the checkpoint for this thread
|
||||
const checkpoint = await this.checkpointer.getTuple(threadConfig);
|
||||
|
||||
if (checkpoint?.checkpoint) {
|
||||
const messages =
|
||||
(checkpoint.checkpoint.channel_values?.messages as Array<
|
||||
AIMessage | HumanMessage | ToolMessage
|
||||
>) ?? [];
|
||||
|
||||
sessions.push({
|
||||
sessionId: threadId,
|
||||
messages: formatMessages(messages, this.getBuilderTools()),
|
||||
lastUpdated: checkpoint.checkpoint.ts,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Thread doesn't exist yet
|
||||
this.logger?.debug('No session found for workflow:', { workflowId, error });
|
||||
}
|
||||
}
|
||||
|
||||
return { sessions };
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,5 +39,6 @@ export class AiBuilderChatRequestDto extends Z.class({
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
useDeprecatedCredentials: z.boolean().default(false),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
7
packages/@n8n/api-types/src/push/builder-credits.ts
Normal file
7
packages/@n8n/api-types/src/push/builder-credits.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type BuilderCreditsPushMessage = {
|
||||
type: 'updateBuilderCredits';
|
||||
data: {
|
||||
creditsQuota: number;
|
||||
creditsClaimed: number;
|
||||
};
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import type { BuilderCreditsPushMessage } from './builder-credits';
|
||||
import type { CollaborationPushMessage } from './collaboration';
|
||||
import type { DebugPushMessage } from './debug';
|
||||
import type { ExecutionPushMessage } from './execution';
|
||||
@ -13,7 +14,8 @@ export type PushMessage =
|
||||
| WebhookPushMessage
|
||||
| WorkerPushMessage
|
||||
| CollaborationPushMessage
|
||||
| DebugPushMessage;
|
||||
| DebugPushMessage
|
||||
| BuilderCreditsPushMessage;
|
||||
|
||||
export type PushType = PushMessage['type'];
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ export const LICENSE_FEATURES = {
|
||||
API_KEY_SCOPES: 'feat:apiKeyScopes',
|
||||
WORKFLOW_DIFFS: 'feat:workflowDiffs',
|
||||
CUSTOM_ROLES: 'feat:customRoles',
|
||||
AI_BUILDER: 'feat:aiBuilder',
|
||||
} as const;
|
||||
|
||||
export const LICENSE_QUOTAS = {
|
||||
|
||||
@ -122,6 +122,7 @@ describe('AiController', () => {
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'workflow123' },
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -150,6 +151,7 @@ describe('AiController', () => {
|
||||
executionData: undefined,
|
||||
executionSchema: undefined,
|
||||
},
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
request.user,
|
||||
expect.any(AbortSignal),
|
||||
@ -395,4 +397,30 @@ describe('AiController', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuilderCredits', () => {
|
||||
it('should return builder instance credits successfully', async () => {
|
||||
const expectedCredits: AiAssistantSDK.BuilderInstanceCreditsResponse = {
|
||||
creditsQuota: 100,
|
||||
creditsClaimed: 25,
|
||||
};
|
||||
|
||||
workflowBuilderService.getBuilderInstanceCredits.mockResolvedValue(expectedCredits);
|
||||
|
||||
const result = await controller.getBuilderCredits(request, response);
|
||||
|
||||
expect(workflowBuilderService.getBuilderInstanceCredits).toHaveBeenCalledWith(request.user);
|
||||
expect(result).toEqual(expectedCredits);
|
||||
});
|
||||
|
||||
it('should throw InternalServerError if getting credits fails', async () => {
|
||||
const mockError = new Error('Failed to get credits');
|
||||
workflowBuilderService.getBuilderInstanceCredits.mockRejectedValue(mockError);
|
||||
|
||||
await expect(controller.getBuilderCredits(request, response)).rejects.toThrow(
|
||||
InternalServerError,
|
||||
);
|
||||
expect(workflowBuilderService.getBuilderInstanceCredits).toHaveBeenCalledWith(request.user);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
AiSessionRetrievalRequestDto,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Post, RestController } from '@n8n/decorators';
|
||||
import { Body, Get, Licensed, Post, RestController } from '@n8n/decorators';
|
||||
import { type AiAssistantSDK, APIResponseError } from '@n8n_io/ai-assistant-sdk';
|
||||
import { Response } from 'express';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||
@ -39,6 +39,7 @@ export class AiController {
|
||||
// Use usesTemplates flag to bypass the send() wrapper which would cause
|
||||
// "Cannot set headers after they are sent" error for streaming responses.
|
||||
// This ensures errors during streaming are handled within the stream itself.
|
||||
@Licensed('feat:aiBuilder')
|
||||
@Post('/build', { rateLimit: { limit: 100 }, usesTemplates: true })
|
||||
async build(
|
||||
req: AuthenticatedRequest,
|
||||
@ -53,7 +54,7 @@ export class AiController {
|
||||
|
||||
res.on('close', handleClose);
|
||||
|
||||
const { text, workflowContext } = payload.payload;
|
||||
const { text, workflowContext, useDeprecatedCredentials } = payload.payload;
|
||||
const aiResponse = this.workflowBuilderService.chat(
|
||||
{
|
||||
message: text,
|
||||
@ -62,6 +63,7 @@ export class AiController {
|
||||
executionData: workflowContext.executionData,
|
||||
executionSchema: workflowContext.executionSchema,
|
||||
},
|
||||
useDeprecatedCredentials,
|
||||
},
|
||||
req.user,
|
||||
signal,
|
||||
@ -207,6 +209,7 @@ export class AiController {
|
||||
}
|
||||
}
|
||||
|
||||
@Licensed('feat:aiBuilder')
|
||||
@Post('/sessions', { rateLimit: { limit: 100 } })
|
||||
async getSessions(
|
||||
req: AuthenticatedRequest,
|
||||
@ -221,4 +224,18 @@ export class AiController {
|
||||
throw new InternalServerError(e.message, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Licensed('feat:aiBuilder')
|
||||
@Get('/build/credits')
|
||||
async getBuilderCredits(
|
||||
req: AuthenticatedRequest,
|
||||
_: Response,
|
||||
): Promise<AiAssistantSDK.BuilderInstanceCreditsResponse> {
|
||||
try {
|
||||
return await this.workflowBuilderService.getBuilderInstanceCredits(req.user);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
throw new InternalServerError(e.message, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,6 +116,7 @@ export class E2EController {
|
||||
[LICENSE_FEATURES.MFA_ENFORCEMENT]: false,
|
||||
[LICENSE_FEATURES.WORKFLOW_DIFFS]: false,
|
||||
[LICENSE_FEATURES.CUSTOM_ROLES]: false,
|
||||
[LICENSE_FEATURES.AI_BUILDER]: false,
|
||||
};
|
||||
|
||||
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {
|
||||
|
||||
@ -0,0 +1,372 @@
|
||||
import { AiWorkflowBuilderService } from '@n8n/ai-workflow-builder';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IUser } from 'n8n-workflow';
|
||||
|
||||
import type { License } from '@/license';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { Push } from '@/push';
|
||||
import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
jest.mock('@n8n/ai-workflow-builder');
|
||||
jest.mock('@n8n_io/ai-assistant-sdk');
|
||||
|
||||
const MockedAiWorkflowBuilderService = AiWorkflowBuilderService as jest.MockedClass<
|
||||
typeof AiWorkflowBuilderService
|
||||
>;
|
||||
const MockedAiAssistantClient = AiAssistantClient as jest.MockedClass<typeof AiAssistantClient>;
|
||||
|
||||
describe('WorkflowBuilderService', () => {
|
||||
let service: WorkflowBuilderService;
|
||||
let mockNodeTypes: NodeTypes;
|
||||
let mockLicense: License;
|
||||
let mockConfig: GlobalConfig;
|
||||
let mockLogger: Logger;
|
||||
let mockUrlService: UrlService;
|
||||
let mockPush: Push;
|
||||
let mockUser: IUser;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockNodeTypes = mock<NodeTypes>();
|
||||
mockLicense = mock<License>();
|
||||
mockConfig = mock<GlobalConfig>();
|
||||
mockLogger = mock<Logger>();
|
||||
mockUrlService = mock<UrlService>();
|
||||
mockPush = mock<Push>();
|
||||
mockUser = mock<IUser>();
|
||||
mockUser.id = 'test-user-id';
|
||||
|
||||
// Setup default mocks
|
||||
(mockUrlService.getInstanceBaseUrl as jest.Mock).mockReturnValue('https://instance.test.com');
|
||||
(mockLicense.loadCertStr as jest.Mock).mockResolvedValue('test-cert');
|
||||
(mockLicense.getConsumerId as jest.Mock).mockReturnValue('test-consumer-id');
|
||||
mockConfig.aiAssistant = { baseUrl: '' };
|
||||
|
||||
// Reset the mocked AiWorkflowBuilderService
|
||||
MockedAiWorkflowBuilderService.mockClear();
|
||||
MockedAiAssistantClient.mockClear();
|
||||
|
||||
service = new WorkflowBuilderService(
|
||||
mockNodeTypes,
|
||||
mockLicense,
|
||||
mockConfig,
|
||||
mockLogger,
|
||||
mockUrlService,
|
||||
mockPush,
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize without creating the service immediately', () => {
|
||||
expect(MockedAiWorkflowBuilderService).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
it('should create AiWorkflowBuilderService on first chat call without AI assistant client', async () => {
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const mockChatGenerator = (async function* () {
|
||||
yield { messages: ['response'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
const result = await generator.next();
|
||||
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledWith(
|
||||
mockNodeTypes,
|
||||
undefined, // No client when baseUrl is not set
|
||||
mockLogger,
|
||||
'https://instance.test.com',
|
||||
expect.any(Function), // onCreditsUpdated callback
|
||||
);
|
||||
|
||||
expect(result.value).toEqual({ messages: ['response'] });
|
||||
});
|
||||
|
||||
it('should create AiAssistantClient when baseUrl is configured', async () => {
|
||||
mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com';
|
||||
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const mockChatGenerator = (async function* () {
|
||||
yield { messages: ['response'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
expect(MockedAiAssistantClient).toHaveBeenCalledWith({
|
||||
licenseCert: 'test-cert',
|
||||
consumerId: 'test-consumer-id',
|
||||
baseUrl: 'https://ai-assistant.test.com',
|
||||
n8nVersion: expect.any(String),
|
||||
});
|
||||
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledWith(
|
||||
mockNodeTypes,
|
||||
expect.any(AiAssistantClient),
|
||||
mockLogger,
|
||||
'https://instance.test.com',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reuse the same service instance on subsequent calls', async () => {
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const mockChatGenerator1 = (async function* () {
|
||||
yield { messages: ['response1'] };
|
||||
})();
|
||||
const mockChatGenerator2 = (async function* () {
|
||||
yield { messages: ['response2'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock)
|
||||
.mockReturnValueOnce(mockChatGenerator1)
|
||||
.mockReturnValueOnce(mockChatGenerator2);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
// First call
|
||||
const generator1 = service.chat(mockPayload, mockUser);
|
||||
await generator1.next();
|
||||
|
||||
// Second call
|
||||
const generator2 = service.chat(mockPayload, mockUser);
|
||||
await generator2.next();
|
||||
|
||||
// Service should only be created once
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass abort signal to underlying service', async () => {
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
const mockChatGenerator = (async function* () {
|
||||
yield { messages: ['response'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser, abortController.signal);
|
||||
await generator.next();
|
||||
|
||||
expect(mockAiService.chat).toHaveBeenCalledWith(
|
||||
mockPayload,
|
||||
mockUser,
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessions', () => {
|
||||
it('should create service and delegate to getSessions', async () => {
|
||||
const mockSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'test-session',
|
||||
messages: [],
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.getSessions as jest.Mock).mockResolvedValue(mockSessions);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const result = await service.getSessions('workflow-123', mockUser);
|
||||
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
|
||||
expect(mockAiService.getSessions).toHaveBeenCalledWith('workflow-123', mockUser);
|
||||
expect(result).toEqual(mockSessions);
|
||||
});
|
||||
|
||||
it('should handle undefined workflowId', async () => {
|
||||
const mockSessions = { sessions: [] };
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.getSessions as jest.Mock).mockResolvedValue(mockSessions);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const result = await service.getSessions(undefined, mockUser);
|
||||
|
||||
expect(mockAiService.getSessions).toHaveBeenCalledWith(undefined, mockUser);
|
||||
expect(result).toEqual(mockSessions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCreditsUpdated callback', () => {
|
||||
it('should send push notification when credits are updated', async () => {
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const mockChatGenerator = (async function* () {
|
||||
yield { messages: ['response'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
|
||||
|
||||
let capturedCallback:
|
||||
| ((userId: string, creditsQuota: number, creditsClaimed: number) => void)
|
||||
| undefined;
|
||||
|
||||
MockedAiWorkflowBuilderService.mockImplementation(
|
||||
(_nodeTypes, _client, _logger, _instanceUrl, callback) => {
|
||||
capturedCallback = callback;
|
||||
return mockAiService;
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger service creation
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Verify callback was provided
|
||||
expect(capturedCallback).toBeDefined();
|
||||
|
||||
// Simulate credits update
|
||||
capturedCallback!('user-123', 100, 5);
|
||||
|
||||
// Verify push notification was sent
|
||||
expect(mockPush.sendToUsers).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'updateBuilderCredits',
|
||||
data: {
|
||||
creditsQuota: 100,
|
||||
creditsClaimed: 5,
|
||||
},
|
||||
},
|
||||
['user-123'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple credit updates', async () => {
|
||||
const mockPayload = {
|
||||
message: 'test message',
|
||||
workflowContext: {},
|
||||
};
|
||||
|
||||
const mockChatGenerator = (async function* () {
|
||||
yield { messages: ['response'] };
|
||||
})();
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator);
|
||||
|
||||
let capturedCallback:
|
||||
| ((userId: string, creditsQuota: number, creditsClaimed: number) => void)
|
||||
| undefined;
|
||||
|
||||
MockedAiWorkflowBuilderService.mockImplementation(
|
||||
(_nodeTypes, _client, _logger, _instanceUrl, callback) => {
|
||||
capturedCallback = callback;
|
||||
return mockAiService;
|
||||
},
|
||||
);
|
||||
|
||||
const generator = service.chat(mockPayload, mockUser);
|
||||
await generator.next();
|
||||
|
||||
// Simulate multiple credit updates
|
||||
capturedCallback!('user-123', 100, 5);
|
||||
capturedCallback!('user-456', 50, 2);
|
||||
|
||||
// Verify both notifications were sent
|
||||
expect(mockPush.sendToUsers).toHaveBeenCalledTimes(2);
|
||||
expect(mockPush.sendToUsers).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
type: 'updateBuilderCredits',
|
||||
data: {
|
||||
creditsQuota: 100,
|
||||
creditsClaimed: 5,
|
||||
},
|
||||
},
|
||||
['user-123'],
|
||||
);
|
||||
expect(mockPush.sendToUsers).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
type: 'updateBuilderCredits',
|
||||
data: {
|
||||
creditsQuota: 50,
|
||||
creditsClaimed: 2,
|
||||
},
|
||||
},
|
||||
['user-456'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuilderInstanceCredits', () => {
|
||||
it('should return builder instance credits', async () => {
|
||||
const expectedCredits = {
|
||||
creditsQuota: 100,
|
||||
creditsClaimed: 25,
|
||||
};
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
const result = await service.getBuilderInstanceCredits(mockUser);
|
||||
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
|
||||
expect(mockAiService.getBuilderInstanceCredits).toHaveBeenCalledWith(mockUser);
|
||||
expect(result).toEqual(expectedCredits);
|
||||
});
|
||||
|
||||
it('should reuse existing service instance', async () => {
|
||||
const expectedCredits = {
|
||||
creditsQuota: 50,
|
||||
creditsClaimed: 10,
|
||||
};
|
||||
|
||||
const mockAiService = mock<AiWorkflowBuilderService>();
|
||||
(mockAiService.getBuilderInstanceCredits as jest.Mock).mockResolvedValue(expectedCredits);
|
||||
MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService);
|
||||
|
||||
// Call twice to test service reuse
|
||||
await service.getBuilderInstanceCredits(mockUser);
|
||||
const result = await service.getBuilderInstanceCredits(mockUser);
|
||||
|
||||
// Should only create the service once
|
||||
expect(MockedAiWorkflowBuilderService).toHaveBeenCalledTimes(1);
|
||||
expect(mockAiService.getBuilderInstanceCredits).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual(expectedCredits);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -9,6 +9,7 @@ import type { IUser } from 'n8n-workflow';
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import { License } from '@/license';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { Push } from '@/push';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
/**
|
||||
@ -25,6 +26,7 @@ export class WorkflowBuilderService {
|
||||
private readonly config: GlobalConfig,
|
||||
private readonly logger: Logger,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly push: Push,
|
||||
) {}
|
||||
|
||||
private async getService(): Promise<AiWorkflowBuilderService> {
|
||||
@ -45,11 +47,26 @@ export class WorkflowBuilderService {
|
||||
});
|
||||
}
|
||||
|
||||
// Create callback that uses the push service
|
||||
const onCreditsUpdated = (userId: string, creditsQuota: number, creditsClaimed: number) => {
|
||||
this.push.sendToUsers(
|
||||
{
|
||||
type: 'updateBuilderCredits',
|
||||
data: {
|
||||
creditsQuota,
|
||||
creditsClaimed,
|
||||
},
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
};
|
||||
|
||||
this.service = new AiWorkflowBuilderService(
|
||||
this.nodeTypes,
|
||||
client,
|
||||
this.logger,
|
||||
this.urlService.getInstanceBaseUrl(),
|
||||
onCreditsUpdated,
|
||||
);
|
||||
}
|
||||
return this.service;
|
||||
@ -65,4 +82,9 @@ export class WorkflowBuilderService {
|
||||
const sessions = await service.getSessions(workflowId, user);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async getBuilderInstanceCredits(user: IUser) {
|
||||
const service = await this.getService();
|
||||
return await service.getBuilderInstanceCredits(user);
|
||||
}
|
||||
}
|
||||
|
||||
376
packages/frontend/editor-ui/src/api/ai.test.ts
Normal file
376
packages/frontend/editor-ui/src/api/ai.test.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import { chatWithBuilder } from './ai';
|
||||
import * as apiUtils from '@n8n/rest-api-client';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import type { ChatRequest } from '@/types/assistant.types';
|
||||
import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('@n8n/rest-api-client');
|
||||
|
||||
describe('API: ai', () => {
|
||||
describe('chatWithBuilder', () => {
|
||||
let mockContext: IRestApiContext;
|
||||
let mockOnMessageUpdated: ReturnType<typeof vi.fn>;
|
||||
let mockOnDone: ReturnType<typeof vi.fn>;
|
||||
let mockOnError: ReturnType<typeof vi.fn>;
|
||||
let streamRequestSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
baseUrl: 'http://test-base-url',
|
||||
sessionId: 'test-session',
|
||||
pushRef: 'test-ref',
|
||||
} as IRestApiContext;
|
||||
|
||||
mockOnMessageUpdated = vi.fn();
|
||||
mockOnDone = vi.fn();
|
||||
mockOnError = vi.fn();
|
||||
|
||||
streamRequestSpy = vi
|
||||
.spyOn(apiUtils, 'streamRequest')
|
||||
.mockImplementation(async () => await Promise.resolve());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call streamRequest with the correct parameters', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass abort signal when provided', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
|
||||
chatWithBuilder(
|
||||
mockContext,
|
||||
payload,
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
abortSignal,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use deprecated credentials when flag is true', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
sessionId: 'session-456',
|
||||
};
|
||||
|
||||
chatWithBuilder(
|
||||
mockContext,
|
||||
payload,
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: true,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle InitSupportChat payload type', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'init-support-chat',
|
||||
user: {
|
||||
firstName: 'John',
|
||||
},
|
||||
question: 'How do I fix this error?',
|
||||
workflowContext: {
|
||||
currentWorkflow: {
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle EventRequestPayload type', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'event',
|
||||
eventName: 'node-execution-succeeded',
|
||||
},
|
||||
sessionId: 'session-789',
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call callbacks correctly when streamRequest resolves', async () => {
|
||||
const responseData: ChatRequest.ResponsePayload = {
|
||||
sessionId: 'session-123',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
type: 'message' as const,
|
||||
text: 'I will help you build that workflow.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
streamRequestSpy.mockImplementation(
|
||||
async (
|
||||
_ctx: unknown,
|
||||
_url: unknown,
|
||||
_payload: unknown,
|
||||
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
|
||||
onDone: () => void,
|
||||
_onError: unknown,
|
||||
) => {
|
||||
onMessageUpdated(responseData);
|
||||
onDone();
|
||||
return await Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockOnMessageUpdated).toHaveBeenCalledWith(responseData);
|
||||
expect(mockOnDone).toHaveBeenCalled();
|
||||
expect(mockOnError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onError when streamRequest rejects', async () => {
|
||||
const error = new Error('Stream request failed');
|
||||
|
||||
streamRequestSpy.mockImplementation(
|
||||
async (
|
||||
_ctx: unknown,
|
||||
_url: unknown,
|
||||
_payload: unknown,
|
||||
_onMessageUpdated: unknown,
|
||||
_onDone: unknown,
|
||||
onError: (e: Error) => void,
|
||||
) => {
|
||||
onError(error);
|
||||
return await Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockOnError).toHaveBeenCalledWith(error);
|
||||
expect(mockOnDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex workflow context in payload', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Improve my workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: {
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
},
|
||||
executionSchema: [
|
||||
{
|
||||
nodeName: 'HTTP Request',
|
||||
schema: {
|
||||
type: 'object',
|
||||
value: [],
|
||||
path: 'data',
|
||||
key: 'data',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sessionId: 'session-complex',
|
||||
};
|
||||
|
||||
chatWithBuilder(
|
||||
mockContext,
|
||||
payload,
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined parameters correctly', () => {
|
||||
const payload: ChatRequest.RequestPayload = {
|
||||
payload: {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Build me a workflow',
|
||||
},
|
||||
};
|
||||
|
||||
chatWithBuilder(mockContext, payload, mockOnMessageUpdated, mockOnDone, mockOnError);
|
||||
|
||||
expect(streamRequestSpy).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
'/ai/build',
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials: false,
|
||||
},
|
||||
},
|
||||
mockOnMessageUpdated,
|
||||
mockOnDone,
|
||||
mockOnError,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -14,11 +14,18 @@ export function chatWithBuilder(
|
||||
onDone: () => void,
|
||||
onError: (e: Error) => void,
|
||||
abortSignal?: AbortSignal,
|
||||
useDeprecatedCredentials = false,
|
||||
): void {
|
||||
void streamRequest<ChatRequest.ResponsePayload>(
|
||||
ctx,
|
||||
'/ai/build',
|
||||
payload,
|
||||
{
|
||||
...payload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
useDeprecatedCredentials,
|
||||
},
|
||||
},
|
||||
onMessageUpdated,
|
||||
onDone,
|
||||
onError,
|
||||
|
||||
@ -783,7 +783,13 @@ export const NDV_UI_OVERHAUL_EXPERIMENT = {
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const WORKFLOW_BUILDER_EXPERIMENT = {
|
||||
export const WORKFLOW_BUILDER_RELEASE_EXPERIMENT = {
|
||||
name: '043_workflow_builder_release',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT = {
|
||||
name: '036_workflow_builder_agent',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
@ -830,7 +836,8 @@ export const READY_TO_RUN_V2_EXPERIMENT = {
|
||||
};
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
WORKFLOW_BUILDER_EXPERIMENT.name,
|
||||
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name,
|
||||
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
NDV_UI_OVERHAUL_EXPERIMENT.name,
|
||||
|
||||
@ -9,7 +9,11 @@ import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { defaultSettings } from '../__tests__/defaults';
|
||||
import merge from 'lodash/merge';
|
||||
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.store.test';
|
||||
import { WORKFLOW_BUILDER_EXPERIMENT, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
|
||||
import {
|
||||
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
|
||||
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
|
||||
DEFAULT_NEW_WORKFLOW_NAME,
|
||||
} from '@/constants';
|
||||
import { reactive } from 'vue';
|
||||
import * as chatAPI from '@/api/ai';
|
||||
import * as telemetryModule from '@/composables/useTelemetry';
|
||||
@ -416,17 +420,51 @@ describe('AI Builder store', () => {
|
||||
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(true);
|
||||
});
|
||||
|
||||
// Split into two separate tests to avoid caching issues with computed properties
|
||||
it('should return true when experiment flag is set to variant', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.variant);
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(true);
|
||||
});
|
||||
describe('isAIBuilderEnabled computed property', () => {
|
||||
it('should return true when release experiment is set to variant', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control; // deprecated should be control
|
||||
});
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when experiment flag is set to control', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.control);
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(false);
|
||||
it('should return true when release experiment is control but deprecated experiment is variant', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
|
||||
});
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when both experiments are set to control', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
|
||||
});
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize release experiment over deprecated experiment', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
|
||||
}
|
||||
// Even if deprecated is control, release variant should win
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
|
||||
});
|
||||
expect(builderStore.isAIBuilderEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize builder chat session with prompt', async () => {
|
||||
@ -704,7 +742,7 @@ describe('AI Builder store', () => {
|
||||
// Verify the API was called with correct parameters
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
const callArgs = apiSpy.mock.calls[0];
|
||||
expect(callArgs).toHaveLength(6); // Should have 6 arguments
|
||||
expect(callArgs).toHaveLength(7); // Should have 7 arguments
|
||||
|
||||
const signal = callArgs[5]; // The 6th argument is the abort signal
|
||||
expect(signal).toBeDefined();
|
||||
@ -1044,4 +1082,90 @@ describe('AI Builder store', () => {
|
||||
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeprecatedCredentials logic in sendChatMessage', () => {
|
||||
it('should set useDeprecatedCredentials to true when release experiment is control and deprecated experiment is variant', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
// Mock posthog to return control for release and variant for deprecated
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
|
||||
});
|
||||
|
||||
// Mock the API to capture the arguments
|
||||
apiSpy.mockImplementationOnce(() => {});
|
||||
|
||||
builderStore.sendChatMessage({ text: 'test message' });
|
||||
|
||||
// Verify chatWithBuilder was called with useDeprecatedCredentials = true
|
||||
expect(apiSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // rootStore.restApiContext
|
||||
expect.anything(), // payload
|
||||
expect.anything(), // onMessage callback
|
||||
expect.anything(), // onDone callback
|
||||
expect.anything(), // onError callback
|
||||
expect.anything(), // abort signal
|
||||
true, // useDeprecatedCredentials
|
||||
);
|
||||
});
|
||||
|
||||
it('should set useDeprecatedCredentials to false when release experiment is variant', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
// Mock posthog to return variant for release (regardless of deprecated)
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
|
||||
});
|
||||
|
||||
// Mock the API to capture the arguments
|
||||
apiSpy.mockImplementationOnce(() => {});
|
||||
|
||||
builderStore.sendChatMessage({ text: 'test message' });
|
||||
|
||||
// Verify chatWithBuilder was called with useDeprecatedCredentials = false
|
||||
expect(apiSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // rootStore.restApiContext
|
||||
expect.anything(), // payload
|
||||
expect.anything(), // onMessage callback
|
||||
expect.anything(), // onDone callback
|
||||
expect.anything(), // onError callback
|
||||
expect.anything(), // abort signal
|
||||
false, // useDeprecatedCredentials
|
||||
);
|
||||
});
|
||||
|
||||
it('should set useDeprecatedCredentials to false when both experiments are control', () => {
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
// Mock posthog to return control for both experiments
|
||||
vi.spyOn(posthogStore, 'getVariant').mockImplementation((experimentName) => {
|
||||
if (experimentName === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) {
|
||||
return WORKFLOW_BUILDER_RELEASE_EXPERIMENT.control;
|
||||
}
|
||||
return WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.control;
|
||||
});
|
||||
|
||||
// Mock the API to capture the arguments
|
||||
apiSpy.mockImplementationOnce(() => {});
|
||||
|
||||
builderStore.sendChatMessage({ text: 'test message' });
|
||||
|
||||
// Verify chatWithBuilder was called with useDeprecatedCredentials = false
|
||||
expect(apiSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // rootStore.restApiContext
|
||||
expect.anything(), // payload
|
||||
expect.anything(), // onMessage callback
|
||||
expect.anything(), // onDone callback
|
||||
expect.anything(), // onError callback
|
||||
expect.anything(), // abort signal
|
||||
false, // useDeprecatedCredentials
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,8 @@ import {
|
||||
DEFAULT_NEW_WORKFLOW_NAME,
|
||||
ASK_AI_SLIDE_OUT_DURATION_MS,
|
||||
EDITABLE_CANVAS_VIEWS,
|
||||
WORKFLOW_BUILDER_EXPERIMENT,
|
||||
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT,
|
||||
WORKFLOW_BUILDER_RELEASE_EXPERIMENT,
|
||||
} from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import type { ChatUI } from '@n8n/design-system/types/assistant';
|
||||
@ -84,9 +85,16 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value);
|
||||
|
||||
const isAIBuilderEnabled = computed(() => {
|
||||
const releaseExperimentVariant = posthogStore.getVariant(
|
||||
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name,
|
||||
);
|
||||
if (releaseExperimentVariant === WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
posthogStore.getVariant(WORKFLOW_BUILDER_EXPERIMENT.name) ===
|
||||
WORKFLOW_BUILDER_EXPERIMENT.variant
|
||||
posthogStore.getVariant(WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name) ===
|
||||
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant
|
||||
);
|
||||
});
|
||||
|
||||
@ -273,6 +281,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
executionData: executionResult,
|
||||
nodesForSchema: Object.keys(workflowsStore.nodesByName),
|
||||
});
|
||||
|
||||
const retry = createRetryHandler(messageId, async () => sendChatMessage(options));
|
||||
|
||||
// Abort previous streaming request if any
|
||||
@ -280,6 +289,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
streamingAbortController.value.abort();
|
||||
}
|
||||
|
||||
const useDeprecatedCredentials =
|
||||
posthogStore.getVariant(WORKFLOW_BUILDER_RELEASE_EXPERIMENT.name) !==
|
||||
WORKFLOW_BUILDER_RELEASE_EXPERIMENT.variant &&
|
||||
posthogStore.getVariant(WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.name) ===
|
||||
WORKFLOW_BUILDER_DEPRECATED_EXPERIMENT.variant;
|
||||
|
||||
streamingAbortController.value = new AbortController();
|
||||
try {
|
||||
chatWithBuilder(
|
||||
@ -305,6 +320,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
() => stopStreaming(),
|
||||
(e) => handleServiceError(e, messageId, retry),
|
||||
streamingAbortController.value?.signal,
|
||||
useDeprecatedCredentials,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
handleServiceError(e, messageId, retry);
|
||||
|
||||
92
pnpm-lock.yaml
generated
92
pnpm-lock.yaml
generated
@ -22,8 +22,8 @@ catalogs:
|
||||
specifier: 0.3.20-12
|
||||
version: 0.3.20-12
|
||||
'@n8n_io/ai-assistant-sdk':
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
specifier: 1.17.0
|
||||
version: 1.17.0
|
||||
'@sentry/node':
|
||||
specifier: ^9.42.1
|
||||
version: 9.42.1
|
||||
@ -420,7 +420,7 @@ importers:
|
||||
version: link:../di
|
||||
'@n8n_io/ai-assistant-sdk':
|
||||
specifier: 'catalog:'
|
||||
version: 1.15.0
|
||||
version: 1.17.0
|
||||
langsmith:
|
||||
specifier: ^0.3.45
|
||||
version: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
|
||||
@ -1044,7 +1044,7 @@ importers:
|
||||
version: 4.3.0
|
||||
'@getzep/zep-cloud':
|
||||
specifier: 1.0.12
|
||||
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
|
||||
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
|
||||
'@getzep/zep-js':
|
||||
specifier: 0.9.0
|
||||
version: 0.9.0
|
||||
@ -1071,7 +1071,7 @@ importers:
|
||||
version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)
|
||||
'@langchain/community':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)
|
||||
version: 0.3.50(8ac6ecc2064042e5620199e694862b5d)
|
||||
'@langchain/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
|
||||
@ -1194,7 +1194,7 @@ importers:
|
||||
version: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
langchain:
|
||||
specifier: 0.3.33
|
||||
version: 0.3.33(f461b118585bdb288345da9017188aa6)
|
||||
version: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
|
||||
lodash:
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.21
|
||||
@ -1500,7 +1500,7 @@ importers:
|
||||
version: 0.3.20-12(@sentry/node@9.42.1)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.15.0)(pg@8.12.0)(redis@4.6.14)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2))
|
||||
'@n8n_io/ai-assistant-sdk':
|
||||
specifier: 'catalog:'
|
||||
version: 1.15.0
|
||||
version: 1.17.0
|
||||
'@n8n_io/license-sdk':
|
||||
specifier: 2.23.0
|
||||
version: 2.23.0
|
||||
@ -4307,24 +4307,28 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@1.9.0':
|
||||
resolution: {integrity: sha512-l8U2lcqsl9yKPP5WUdIrKH//C1pWyM2cSUfcTBn6GSvXmsSjBNEdGSdM4Wfne777Oe/9ONaD1Ga53U2HksHHLw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@1.9.0':
|
||||
resolution: {integrity: sha512-N3enoFoIrkB6qJWyYfTiYmFdB1R/Mrij1dd1xBHqxxCKZY9GRkEswRX3F1Uqzo5T+9Iu8nAQobDqI/ygicYy/Q==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@1.9.0':
|
||||
resolution: {integrity: sha512-8jAzjrrJTj510pwq4aVs7ZKkOvEy1D+nzl9DKvrPh4TOyUw5Ie+0EDwXGE2RAkCKHkGNOQBZ78WtIdsATgz5sA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@1.9.0':
|
||||
resolution: {integrity: sha512-AIjwJTGfdWGMRluSQ9pDB29nzce077dfHh0/HMqzztKzgD3spyuo2R9VoaFpbR0hLHPWEH6g6OxxDO7hfkXNkQ==}
|
||||
@ -4941,67 +4945,79 @@ packages:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.33.5':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.33.5':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.33.5':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.33.5':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@ -5885,8 +5901,8 @@ packages:
|
||||
engines: {node: '>=18.10', pnpm: '>=9.6'}
|
||||
hasBin: true
|
||||
|
||||
'@n8n_io/ai-assistant-sdk@1.15.0':
|
||||
resolution: {integrity: sha512-M/bNnxyVGxwLGU/mzQrZOkZK4NkR9x8cUMZHfVJlv1z6YTlHX56BYH+0jSlb2c15DEwPkku9l0RFVLTTt0ExQQ==}
|
||||
'@n8n_io/ai-assistant-sdk@1.17.0':
|
||||
resolution: {integrity: sha512-Zwfgf9N4aK9klCVC15xHL8R5ID8h9f6OAlW6fPJRV00cmBjX2gD8ZYaX92A9iGiKpmW5YG3mxPU7XTFVexB7wQ==}
|
||||
engines: {node: '>=20.15', pnpm: '>=8.14'}
|
||||
|
||||
'@n8n_io/license-sdk@2.23.0':
|
||||
@ -5925,30 +5941,35 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.70':
|
||||
resolution: {integrity: sha512-wBTOllEYNfJCHOdZj9v8gLzZ4oY3oyPX8MSRvaxPm/s7RfEXxCyZ8OhJ5xAyicsDdbE5YBZqdmaaeP5+xKxvtg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.70':
|
||||
resolution: {integrity: sha512-GVUUPC8TuuFqHip0rxHkUqArQnlzmlXmTEBuXAWdgCv85zTCFH8nOHk/YCF5yo0Z2eOm8nOi90aWs0leJ4OE5Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.70':
|
||||
resolution: {integrity: sha512-/kvUa2lZRwGNyfznSn5t1ShWJnr/m5acSlhTV3eXECafObjl0VBuA1HJw0QrilLpb4Fe0VLywkpD1NsMoVDROQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.70':
|
||||
resolution: {integrity: sha512-aqlv8MLpycoMKRmds7JWCfVwNf1fiZxaU7JwJs9/ExjTD8lX2KjsO7CTeAj5Cl4aEuzxUWbJPUUE2Qu9cZ1vfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.70':
|
||||
resolution: {integrity: sha512-Q9QU3WIpwBTVHk4cPfBjGHGU4U0llQYRXgJtFtYqqGNEOKVN4OT6PQ+ve63xwIPODMpZ0HHyj/KLGc9CWc3EtQ==}
|
||||
@ -6251,36 +6272,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@ -6477,56 +6504,67 @@ packages:
|
||||
resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.49.0':
|
||||
resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.49.0':
|
||||
resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.49.0':
|
||||
resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.49.0':
|
||||
resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.49.0':
|
||||
resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.49.0':
|
||||
resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==}
|
||||
@ -7813,41 +7851,49 @@ packages:
|
||||
resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.9.2':
|
||||
resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==}
|
||||
@ -16355,8 +16401,8 @@ packages:
|
||||
vue-component-type-helpers@2.2.12:
|
||||
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
|
||||
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
vue-component-type-helpers@3.1.0-alpha.0:
|
||||
resolution: {integrity: sha512-K1guwS1Oy0gNfBdIdIn8JMkUV+S38sriR1zf5dP+KkPS7/r5nHnPZUL74meY2CYlxYBH4qSQ+k7bpHfwiRvaMg==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@ -19288,7 +19334,7 @@ snapshots:
|
||||
'@gar/promisify@1.1.3':
|
||||
optional: true
|
||||
|
||||
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))':
|
||||
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))':
|
||||
dependencies:
|
||||
form-data: 4.0.4
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
@ -19297,7 +19343,7 @@ snapshots:
|
||||
zod: 3.25.67
|
||||
optionalDependencies:
|
||||
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
|
||||
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
|
||||
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
@ -19864,7 +19910,7 @@ snapshots:
|
||||
- aws-crt
|
||||
- encoding
|
||||
|
||||
'@langchain/community@0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)':
|
||||
'@langchain/community@0.3.50(8ac6ecc2064042e5620199e694862b5d)':
|
||||
dependencies:
|
||||
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(utf-8-validate@5.0.10)(zod@3.25.67)
|
||||
'@ibm-cloud/watsonx-ai': 1.1.2
|
||||
@ -19876,7 +19922,7 @@ snapshots:
|
||||
flat: 5.0.2
|
||||
ibm-cloud-sdk-core: 5.3.2
|
||||
js-yaml: 4.1.0
|
||||
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
|
||||
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
|
||||
langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
|
||||
openai: 5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)
|
||||
uuid: 10.0.0
|
||||
@ -19890,7 +19936,7 @@ snapshots:
|
||||
'@aws-sdk/credential-provider-node': 3.808.0
|
||||
'@azure/storage-blob': 12.26.0
|
||||
'@browserbasehq/sdk': 2.6.0(encoding@0.1.13)
|
||||
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
|
||||
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
|
||||
'@getzep/zep-js': 0.9.0
|
||||
'@google-ai/generativelanguage': 3.4.0(encoding@0.1.13)
|
||||
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
|
||||
@ -20326,7 +20372,7 @@ snapshots:
|
||||
acorn: 8.12.1
|
||||
acorn-walk: 8.3.4
|
||||
|
||||
'@n8n_io/ai-assistant-sdk@1.15.0': {}
|
||||
'@n8n_io/ai-assistant-sdk@1.17.0': {}
|
||||
|
||||
'@n8n_io/license-sdk@2.23.0':
|
||||
dependencies:
|
||||
@ -21796,7 +21842,7 @@ snapshots:
|
||||
storybook: 9.1.7(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@5.0.10)(vite@7.0.0(@types/node@20.19.11)(jiti@1.21.7)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.7
|
||||
vue-component-type-helpers: 3.1.0-alpha.0
|
||||
|
||||
'@stylistic/eslint-plugin@5.0.0(eslint@9.29.0(jiti@1.21.7))':
|
||||
dependencies:
|
||||
@ -27023,7 +27069,7 @@ snapshots:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.19.11
|
||||
'@types/tough-cookie': 4.0.5
|
||||
axios: 1.12.0(debug@4.4.1)
|
||||
axios: 1.12.0(debug@4.3.6)
|
||||
camelcase: 6.3.0
|
||||
debug: 4.4.1(supports-color@8.1.1)
|
||||
dotenv: 16.6.1
|
||||
@ -27033,7 +27079,7 @@ snapshots:
|
||||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.2
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.12.0)
|
||||
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1))
|
||||
tough-cookie: 4.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -28291,7 +28337,7 @@ snapshots:
|
||||
|
||||
kuler@2.0.0: {}
|
||||
|
||||
langchain@0.3.33(f461b118585bdb288345da9017188aa6):
|
||||
langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e):
|
||||
dependencies:
|
||||
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
|
||||
'@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))
|
||||
@ -31104,7 +31150,7 @@ snapshots:
|
||||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
retry-axios@2.6.0(axios@1.12.0):
|
||||
retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)):
|
||||
dependencies:
|
||||
axios: 1.12.0(debug@4.3.6)
|
||||
|
||||
@ -33300,7 +33346,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.12: {}
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
vue-component-type-helpers@3.1.0-alpha.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@ -12,7 +12,7 @@ minimumReleaseAgeExclude:
|
||||
|
||||
catalog:
|
||||
'@n8n/typeorm': 0.3.20-12
|
||||
'@n8n_io/ai-assistant-sdk': 1.15.0
|
||||
'@n8n_io/ai-assistant-sdk': 1.17.0
|
||||
'@langchain/core': 0.3.68
|
||||
'@langchain/openai': 0.6.7
|
||||
'@langchain/anthropic': 0.3.26
|
||||
|
||||
Loading…
Reference in New Issue
Block a user