From 5bddbedf2eb5b6363e24e20dccfea267b80001fb Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 9 Oct 2025 15:33:26 +0200 Subject: [PATCH 1/2] feat(API): Add project and projectId fields to get and update a variable project (#20544) --- .../variables/variables.service.ee.ts | 7 +- .../variables/spec/paths/variables.id.yml | 2 +- .../variables/spec/paths/variables.yml | 16 ++- .../spec/schemas/variable.create.yml | 21 ++++ .../variables/spec/schemas/variable.yml | 2 + .../handlers/variables/variables.handler.ts | 17 +-- packages/cli/src/requests.ts | 14 ++- .../integration/public-api/variables.test.ts | 115 ++++++++++++++++-- .../test/integration/shared/db/variables.ts | 5 +- .../cli/test/integration/variables.test.ts | 20 ++- 10 files changed, 195 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.create.yml diff --git a/packages/cli/src/environments.ee/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts index 0961f15800d..051a73455ca 100644 --- a/packages/cli/src/environments.ee/variables/variables.service.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.service.ee.ts @@ -261,7 +261,12 @@ export class VariablesService { await this.variablesRepository.update(id, { key: variable.key, value: variable.value, - project: variable.projectId ? { id: variable.projectId } : null, + // Only update the project if it was explicitly set in the update + // If project id is undefined, keep the existing + // If project id is null, move to global (no project) + ...(typeof variable.projectId !== 'undefined' + ? { project: variable.projectId ? { id: variable.projectId } : null } + : {}), }); await this.updateCache(); return (await this.getCached(id))!; diff --git a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml index bbc26094d6b..46edf8ee25a 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml +++ b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.id.yml @@ -29,7 +29,7 @@ put: content: application/json: schema: - $ref: '../schemas/variable.yml' + $ref: '../schemas/variable.create.yml' required: true responses: '204': diff --git a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.yml b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.yml index 7418c2fe057..1d75ee5211f 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.yml +++ b/packages/cli/src/public-api/v1/handlers/variables/spec/paths/variables.yml @@ -10,7 +10,7 @@ post: content: application/json: schema: - $ref: '../schemas/variable.yml' + $ref: '../schemas/variable.create.yml' required: true responses: '201': @@ -29,6 +29,20 @@ get: parameters: - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM + - name: state + in: query + required: false + schema: + type: string + enum: [empty] responses: '200': description: Operation successful. diff --git a/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.create.yml b/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.create.yml new file mode 100644 index 00000000000..05c37437dfb --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.create.yml @@ -0,0 +1,21 @@ +type: object +additionalProperties: false +required: + - key + - value +properties: + id: + type: string + readOnly: true + key: + type: string + value: + type: string + example: test + type: + type: string + readOnly: true + projectId: + type: string + example: VmwOO9HeTEj20kxM + nullable: true diff --git a/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.yml b/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.yml index 319ad8d4403..283f62ce534 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.yml +++ b/packages/cli/src/public-api/v1/handlers/variables/spec/schemas/variable.yml @@ -15,3 +15,5 @@ properties: type: type: string readOnly: true + project: + $ref: '../../../projects/spec/schemas/project.yml' diff --git a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts index 08be7fe72a9..d5fdc681d3e 100644 --- a/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/variables/variables.handler.ts @@ -2,6 +2,8 @@ import { CreateVariableRequestDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { VariablesRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { IsNull } from '@n8n/typeorm'; import type { Response } from 'express'; import { @@ -12,12 +14,8 @@ import { import { encodeNextCursor } from '../../shared/services/pagination.service'; import { VariablesController } from '@/environments.ee/variables/variables.controller.ee'; -import type { PaginatedRequest } from '@/public-api/types'; import type { VariablesRequest } from '@/requests'; -type Delete = VariablesRequest.Delete; -type GetAll = PaginatedRequest; - export = { createVariable: [ isLicensed('feat:variables'), @@ -48,7 +46,7 @@ export = { deleteVariable: [ isLicensed('feat:variables'), apiKeyHasScopeWithGlobalScopeFallback({ scope: 'variable:delete' }), - async (req: Delete, res: Response) => { + async (req: AuthenticatedRequest<{ id: string }>, res: Response) => { await Container.get(VariablesController).deleteVariable(req); return res.status(204).send(); @@ -58,12 +56,17 @@ export = { isLicensed('feat:variables'), apiKeyHasScopeWithGlobalScopeFallback({ scope: 'variable:list' }), validCursor, - async (req: GetAll, res: Response) => { - const { offset = 0, limit = 100 } = req.query; + async (req: VariablesRequest.GetAll, res: Response) => { + const { offset = 0, limit = 100, projectId, state } = req.query; const [variables, count] = await Container.get(VariablesRepository).findAndCount({ skip: offset, take: limit, + where: { + project: projectId === 'null' ? IsNull() : { id: projectId }, + value: state === 'empty' ? '' : undefined, + }, + relations: ['project'], }); return res.json({ diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index dc35e87898a..275ded8d284 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -235,7 +235,19 @@ export declare namespace LicenseRequest { export declare namespace VariablesRequest { type CreateUpdatePayload = Omit & { id?: unknown }; - type GetAll = AuthenticatedRequest; + type GetAll = AuthenticatedRequest< + {}, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + lastId?: string; + projectId?: string; + state?: 'empty'; + } + >; type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload, {}>; type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>; diff --git a/packages/cli/test/integration/public-api/variables.test.ts b/packages/cli/test/integration/public-api/variables.test.ts index fa9317b53b8..a62360af72b 100644 --- a/packages/cli/test/integration/public-api/variables.test.ts +++ b/packages/cli/test/integration/public-api/variables.test.ts @@ -1,13 +1,18 @@ -import { testDb } from '@n8n/backend-test-utils'; -import type { User, Variables } from '@n8n/db'; +import { createTeamProject, testDb } from '@n8n/backend-test-utils'; +import type { Project, User, Variables } from '@n8n/db'; +import { createOwnerWithApiKey } from '@test-integration/db/users'; +import { + createProjectVariable, + createVariable, + getVariableByIdOrFail, +} from '@test-integration/db/variables'; +import { setupTestServer } from '@test-integration/utils'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { createOwnerWithApiKey } from '@test-integration/db/users'; -import { createVariable, getVariableByIdOrFail } from '@test-integration/db/variables'; -import { setupTestServer } from '@test-integration/utils'; describe('Variables in Public API', () => { let owner: User; + let project: Project; const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); const licenseErrorMessage = new FeatureNotLicensedError('feat:variables').message; @@ -19,6 +24,7 @@ describe('Variables in Public API', () => { await testDb.truncate(['Variables', 'User']); owner = await createOwnerWithApiKey(); + project = await createTeamProject(); }); describe('GET /variables', () => { @@ -27,7 +33,12 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const variables = await Promise.all([createVariable(), createVariable(), createVariable()]); + const variables = await Promise.all([ + createVariable(), + createVariable(), + createVariable(), + createProjectVariable('projectKey', 'projectValue', project), + ]); /** * Act @@ -43,11 +54,55 @@ describe('Variables in Public API', () => { expect(Array.isArray(response.body.data)).toBe(true); expect(response.body.data.length).toBe(variables.length); - variables.forEach(({ id, key, value }) => { + variables.forEach(({ id, key, value, project }) => { expect(response.body.data).toContainEqual(expect.objectContaining({ id, key, value })); + if (project) { + const projectResponse = response.body.data.find((v: Variables) => v.id === id).project; + expect(projectResponse).toBeDefined(); + expect(projectResponse).toEqual( + expect.objectContaining({ id: project.id, name: project.name }), + ); + } }); }); + it('if licensed, should be able to filter variables by projectId and state', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:variables'); + await Promise.all([ + createVariable(), + createProjectVariable('projectKey', 'projectValue', project), + createProjectVariable('emptyVar', '', project), + createVariable('emptyVar', ''), + ]); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .get('/variables') + .query({ projectId: project.id, state: 'empty' }); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('nextCursor'); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0]).toEqual( + expect.objectContaining({ + key: 'emptyVar', + value: '', + project: expect.objectContaining({ id: project.id }), + }), + ); + }); + it('if not licensed, should reject', async () => { /** * Act @@ -87,6 +142,34 @@ describe('Variables in Public API', () => { ); }); + it('if licensed, should create a variable linked to a project', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:variables'); + const variablePayload = { key: 'key', value: 'value', projectId: project.id }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/variables') + .send(variablePayload); + + /** + * Assert + */ + expect(response.status).toBe(201); + await expect(getVariableByIdOrFail(response.body.id)).resolves.toEqual( + expect.objectContaining({ + key: 'key', + value: 'value', + project: expect.objectContaining({ id: project.id }), + }), + ); + }); + it('if not licensed, should reject', async () => { /** * Arrange @@ -129,6 +212,24 @@ describe('Variables in Public API', () => { expect(updatedVariable).toEqual(expect.objectContaining(variablePayload)); }); + it('if licensed, should update a variable to link it to a project', async () => { + testServer.license.enable('feat:variables'); + + const response = await testServer + .publicApiAgentFor(owner) + .put(`/variables/${variable.id}`) + .send({ ...variablePayload, projectId: project.id }); + + expect(response.status).toBe(204); + const updatedVariable = await getVariableByIdOrFail(variable.id); + expect(updatedVariable).toEqual( + expect.objectContaining({ + ...variablePayload, + project: expect.objectContaining({ id: project.id }), + }), + ); + }); + it('if not licensed, should reject', async () => { const response = await testServer .publicApiAgentFor(owner) diff --git a/packages/cli/test/integration/shared/db/variables.ts b/packages/cli/test/integration/shared/db/variables.ts index ed0fc1b50b6..aaaa96beb24 100644 --- a/packages/cli/test/integration/shared/db/variables.ts +++ b/packages/cli/test/integration/shared/db/variables.ts @@ -32,7 +32,10 @@ export async function createProjectVariable( } export async function getVariableByIdOrFail(id: string) { - return await Container.get(VariablesRepository).findOneOrFail({ where: { id } }); + return await Container.get(VariablesRepository).findOneOrFail({ + where: { id }, + relations: ['project'], + }); } export async function getVariableByKey(key: string) { diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index 6c4b54942f5..28bed4552e9 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -1,9 +1,14 @@ -import { testDb } from '@n8n/backend-test-utils'; -import type { Variables } from '@n8n/db'; +import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; +import type { Project, Variables } from '@n8n/db'; import { Container } from '@n8n/di'; import { CacheService } from '@/services/cache/cache.service'; -import { createVariable, getVariableById, getVariableByKey } from '@test-integration/db/variables'; +import { + createProjectVariable, + createVariable, + getVariableById, + getVariableByKey, +} from '@test-integration/db/variables'; import { createOwner, createUser } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; @@ -11,6 +16,7 @@ import * as utils from './shared/utils/'; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; +let project: Project; const testServer = utils.setupTestServer({ endpointGroups: ['variables'] }); const license = testServer.license; @@ -21,6 +27,9 @@ beforeAll(async () => { const member = await createUser(); authMemberAgent = testServer.authAgentFor(member); + project = await createTeamProject(); + await linkUserToProject(member, project, 'project:editor'); + license.setDefaults({ features: ['feat:variables'], // quota: { @@ -42,6 +51,7 @@ describe('GET /variables', () => { createVariable('test1', 'value1'), createVariable('test2', 'value2'), createVariable('empty', ''), + createProjectVariable('testProject1', 'projectValue1', project), ]); }); @@ -57,13 +67,13 @@ describe('GET /variables', () => { test('should return all variables for an owner', async () => { const response = await authOwnerAgent.get('/variables'); expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(3); + expect(response.body.data.length).toBe(4); }); test('should return all variables for a member', async () => { const response = await authMemberAgent.get('/variables'); expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(3); + expect(response.body.data.length).toBe(4); }); describe('state:empty', () => { From 1a6968b04f3c70e0e00b04cfbcfe25eccaef638e Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Thu, 9 Oct 2025 15:39:23 +0200 Subject: [PATCH 2/2] feat: Select credentials (no-changelog) (#20559) Co-authored-by: Jaakko Husso --- packages/@n8n/api-types/src/chat-hub.ts | 19 +- packages/@n8n/api-types/src/index.ts | 1 + .../modules/chat-hub/chat-hub.controller.ts | 17 +- .../src/modules/chat-hub/chat-hub.service.ts | 333 +++++++++--------- .../src/modules/chat-hub/chat-hub.types.ts | 4 +- .../ask-ai-with-credentials-request.dto.ts | 18 - packages/frontend/editor-ui/src/constants.ts | 1 + .../src/features/chatHub/ChatView.vue | 140 ++++++-- .../src/features/chatHub/chat.api.ts | 16 +- .../src/features/chatHub/chat.store.ts | 31 +- .../src/features/chatHub/chat.types.ts | 7 +- .../src/features/chatHub/chat.utils.ts | 30 +- .../components/CredentialSelectorModal.vue | 122 +++++++ .../chatHub/components/ModelSelector.vue | 54 +-- .../src/features/chatHub/constants.ts | 7 + 15 files changed, 526 insertions(+), 274 deletions(-) delete mode 100644 packages/cli/src/modules/chat-hub/dto/ask-ai-with-credentials-request.dto.ts create mode 100644 packages/frontend/editor-ui/src/features/chatHub/components/CredentialSelectorModal.vue diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 117f91cc575..4606e8f80c2 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { Z } from 'zod-class'; /** * Supported AI model providers @@ -39,4 +40,20 @@ export type ChatModelsRequest = z.infer; /** * Response type for fetching available chat models */ -export type ChatModelsResponse = ChatHubConversationModel[]; +export type ChatModelsResponse = Record< + ChatHubProvider, + { models: Array<{ name: string }>; error?: string } +>; + +export class ChatHubSendMessageRequest extends Z.class({ + messageId: z.string().uuid(), + sessionId: z.string().uuid(), + message: z.string(), + model: chatHubConversationModelSchema, + credentials: z.record( + z.object({ + id: z.string(), + name: z.string(), + }), + ), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index a571f0ae221..4a1d762bcc4 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -15,6 +15,7 @@ export { chatModelsRequestSchema, type ChatModelsRequest, type ChatModelsResponse, + ChatHubSendMessageRequest, } from './chat-hub'; export type { Collaborator } from './push/collaboration'; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 7701b292137..009ad74fd2a 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -1,16 +1,19 @@ -import type { ChatModelsResponse } from '@n8n/api-types'; +import type { ChatHubSendMessageRequest, ChatModelsResponse } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; import { AuthenticatedRequest } from '@n8n/db'; import { RestController, Post, Body, GlobalScope } from '@n8n/decorators'; import type { Response } from 'express'; import { strict as assert } from 'node:assert'; import { ChatHubService } from './chat-hub.service'; -import { AskAiWithCredentialsRequestDto } from './dto/ask-ai-with-credentials-request.dto'; import { ChatModelsRequestDto } from './dto/chat-models-request.dto'; @RestController('/chat') export class ChatHubController { - constructor(private readonly chatService: ChatHubService) {} + constructor( + private readonly chatService: ChatHubService, + private readonly logger: Logger, + ) {} @Post('/models') async getModels( @@ -18,7 +21,7 @@ export class ChatHubController { _res: Response, @Body payload: ChatModelsRequestDto, ): Promise { - return await this.chatService.getModels(req.user.id, payload.credentials); + return await this.chatService.getModels(req.user, payload.credentials); } @GlobalScope('chatHub:message') @@ -26,7 +29,7 @@ export class ChatHubController { async sendMessage( req: AuthenticatedRequest, res: Response, - @Body payload: AskAiWithCredentialsRequestDto, + @Body payload: ChatHubSendMessageRequest, ) { res.header('Content-type', 'application/json-lines; charset=utf-8'); res.header('Transfer-Encoding', 'chunked'); @@ -38,6 +41,8 @@ export class ChatHubController { const replyId = crypto.randomUUID(); + this.logger.info(`Chat send request received: ${JSON.stringify(payload)}`); + try { await this.chatService.askN8n(res, req.user, { ...payload, @@ -47,6 +52,8 @@ export class ChatHubController { } catch (executionError: unknown) { assert(executionError instanceof Error); + this.logger.error('Error in chat send endpoint', { error: executionError }); + if (!res.headersSent) { res.status(500).json({ code: 500, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index ecc47ad81d5..a351d906200 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -1,8 +1,8 @@ import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider, - type ChatHubConversationModel, type ChatModelsResponse, + chatHubProviderSchema, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { @@ -18,10 +18,11 @@ import { import { Service } from '@n8n/di'; import type { Response } from 'express'; import { + AGENT_LANGCHAIN_NODE_TYPE, + CHAT_TRIGGER_NODE_TYPE, OperationalError, type IConnections, type INode, - type INodeCredentialsDetails, type ITaskData, type IWorkflowBase, type StartNodeData, @@ -34,11 +35,13 @@ import { CredentialsHelper } from '@/credentials-helper'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import { CredentialsService } from '@/credentials/credentials.service'; @Service() export class ChatHubService { constructor( private readonly logger: Logger, + private readonly credentialsService: CredentialsService, private readonly credentialsHelper: CredentialsHelper, private readonly executionRepository: ExecutionRepository, private readonly workflowExecutionService: WorkflowExecutionService, @@ -48,32 +51,31 @@ export class ChatHubService { ) {} async getModels( - userId: string, + user: User, credentialIds: Record, ): Promise { - const models: ChatHubConversationModel[] = []; - const additionalData = await getBase({ userId }); + const additionalData = await getBase({ userId: user.id }); - for (const [providerKey, credentialId] of Object.entries(credentialIds)) { - if (!credentialId) { - continue; - } + const responses = await Promise.all( + chatHubProviderSchema.options.map< + Promise<[ChatHubProvider, ChatModelsResponse[ChatHubProvider]]> + >(async (provider) => { + const credentialId = credentialIds[provider]; - // Type assertion is safe here because credentialIds type guarantees valid keys - const provider = providerKey as ChatHubProvider; - const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider]; + if (!credentialId) { + return [provider, { models: [] }]; + } - try { - // Get the decrypted credential data - const nodeCredentials: INodeCredentialsDetails = { - id: credentialId, - name: credentialType, - }; + // Ensure the user has the permission to read the credential + await this.credentialsService.getOne(user, credentialId, false); const credentials = await this.credentialsHelper.getDecrypted( additionalData, - nodeCredentials, - credentialType, + { + id: credentialId, + name: PROVIDER_CREDENTIAL_TYPE_MAP[provider], + }, + PROVIDER_CREDENTIAL_TYPE_MAP[provider], 'internal', undefined, true, @@ -81,25 +83,39 @@ export class ChatHubService { // Extract API key from credentials based on provider const apiKey = this.extractApiKey(provider, credentials); + if (!apiKey) { - continue; + return [provider, { models: [] }]; } - // Fetch models dynamically from the provider - const providerModels = await this.fetchModelsForProvider(provider, apiKey); - models.push(...providerModels); - } catch (error) { - this.logger.debug(`Failed to get models for ${provider}: ${error}`); - } - } + try { + return [provider, await this.fetchModelsForProvider(provider, apiKey)]; + } catch { + return [ + provider, + { models: [], error: 'Could not retrieve models. Verify credentials.' }, + ]; + } + }), + ); - return models; + return responses.reduce( + (acc, [provider, res]) => { + acc[provider] = res; + return acc; + }, + { + openai: { models: [] }, + anthropic: { models: [] }, + google: { models: [] }, + }, + ); } private async fetchModelsForProvider( provider: ChatHubProvider, apiKey: string, - ): Promise { + ): Promise { switch (provider) { case 'openai': return await this.fetchOpenAiModels(apiKey); @@ -110,127 +126,88 @@ export class ChatHubService { } } - private async fetchOpenAiModels(apiKey: string): Promise { - try { - const response = await fetch('https://api.openai.com/v1/models', { + private async fetchOpenAiModels(apiKey: string): Promise { + const response = await fetch('https://api.openai.com/v1/models', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch OpenAI models: ${response.statusText}`); + } + + const data = await response.json(); + + return { + models: data.data + .filter( + (model: { id: string }) => + model.id.includes('gpt') && + !model.id.includes('instruct') && + !model.id.includes('audio'), + ) + .map((model: { id: string }) => ({ name: model.id })), + }; + } + + private async fetchAnthropicModels(apiKey: string): Promise { + const response = await fetch('https://api.anthropic.com/v1/models', { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Anthropic models: ${response.statusText}`); + } + + const data = (await response.json()) as { + data: Array<{ id: string; display_name: string; type: string; created_at: string }>; + }; + + return { + models: (data.data || []) + .sort((a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + return dateB.getTime() - dateA.getTime(); + }) + .map((model) => ({ name: model.id })), + }; + } + + private async fetchGoogleModels(apiKey: string): Promise { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`, + { method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); + }, + ); - if (!response.ok) { - throw new Error(`Failed to fetch OpenAI models: ${response.statusText}`); - } - - const data = await response.json(); - const models: ChatHubConversationModel[] = []; - - // Filter for chat models only (GPT models) - const chatModels = data.data.filter( - (model: { id: string }) => - model.id.includes('gpt') && !model.id.includes('instruct') && !model.id.includes('audio'), - ); - - for (const model of chatModels) { - models.push({ - provider: 'openai', - model: model.id, - }); - } - - return models; - } catch (error) { - this.logger.debug(`Failed to fetch OpenAI models: ${error}`); - return []; + if (!response.ok) { + throw new Error(`Failed to fetch Google models: ${response.statusText}`); } - } - private async fetchAnthropicModels(apiKey: string): Promise { - // Anthropic doesn't have a public models list endpoint, so we'll use a curated list - // but validate the API key first - try { - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: 'claude-3-haiku-20240307', - max_tokens: 1, - messages: [{ role: 'user', content: 'test' }], + const data = await response.json(); + + return { + models: data.models + ?.filter( + (model: { name: string; supportedGenerationMethods?: string[] }) => + model.name.includes('gemini') && + model.supportedGenerationMethods?.includes('generateContent'), + ) + .map((model: { name: string }) => { + // Extract model ID from the full name (e.g., "models/gemini-1.5-pro" -> "gemini-1.5-pro") + const modelId = model.name.split('/').pop(); + + return { name: modelId }; }), - }); - - // If authentication fails, don't return models - if (response.status === 401 || response.status === 403) { - return []; - } - - // Return known Anthropic models - return [ - { - provider: 'anthropic', - model: 'claude-3-5-sonnet-20241022', - }, - { - provider: 'anthropic', - model: 'claude-3-opus-20240229', - }, - { - provider: 'anthropic', - model: 'claude-3-sonnet-20240229', - }, - { - provider: 'anthropic', - model: 'claude-3-haiku-20240307', - }, - ]; - } catch (error) { - this.logger.debug(`Failed to validate Anthropic API key: ${error}`); - return []; - } - } - - private async fetchGoogleModels(apiKey: string): Promise { - try { - const response = await fetch( - `https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`, - { - method: 'GET', - }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch Google models: ${response.statusText}`); - } - - const data = await response.json(); - const models: ChatHubConversationModel[] = []; - - // Filter for Gemini chat models - const chatModels = data.models?.filter( - (model: { name: string; supportedGenerationMethods?: string[] }) => - model.name.includes('gemini') && - model.supportedGenerationMethods?.includes('generateContent'), - ); - - for (const model of chatModels || []) { - // Extract model ID from the full name (e.g., "models/gemini-1.5-pro" -> "gemini-1.5-pro") - const modelId = model.name.split('/').pop(); - models.push({ - provider: 'google', - model: modelId, - }); - } - - return models; - } catch (error) { - this.logger.debug(`Failed to fetch Google models: ${error}`); - return []; - } + }; } private extractApiKey(provider: ChatHubProvider, credentials: unknown): string | undefined { @@ -317,7 +294,7 @@ export class ChatHubService { mode: 'webhook', options: { responseMode: 'streaming' }, }, - type: '@n8n/n8n-nodes-langchain.chatTrigger', + type: CHAT_TRIGGER_NODE_TYPE, typeVersion: 1.3, position: [0, 0], id: uuidv4(), @@ -330,24 +307,13 @@ export class ChatHubService { enableStreaming: true, }, }, - type: '@n8n/n8n-nodes-langchain.agent', + type: AGENT_LANGCHAIN_NODE_TYPE, typeVersion: 3, position: [200, 0], id: uuidv4(), name: 'AI Agent', }, - { - parameters: { - model: { __rl: true, mode: 'list', value: payload.model }, - options: {}, - }, - type: payload.provider, - typeVersion: 1.2, - position: [80, 200], - id: uuidv4(), - name: 'Chat Model', - credentials: payload.credentials, - }, + this.createModelNode(payload), ]; const connections: IConnections = { @@ -439,4 +405,51 @@ export class ChatHubService { res.on('close', onClose); res.on('error', onClose); } + + private createModelNode(payload: ChatPayloadWithCredentials): INode { + const common = { + position: [80, 200] as [number, number], + id: uuidv4(), + name: 'Chat Model', + credentials: payload.credentials, + }; + + switch (payload.model.provider) { + case 'openai': + return { + ...common, + parameters: { + model: { __rl: true, mode: 'list', value: payload.model.model }, + options: {}, + }, + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1.2, + }; + case 'anthropic': + return { + ...common, + parameters: { + model: { + __rl: true, + mode: 'list', + value: payload.model.model, + cachedResultName: payload.model.model, + }, + options: {}, + }, + type: '@n8n/n8n-nodes-langchain.lmChatAnthropic', + typeVersion: 1.3, + }; + case 'google': + return { + ...common, + parameters: { + model: { __rl: true, mode: 'list', value: payload.model.model }, + options: {}, + }, + type: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', + typeVersion: 1.2, + }; + } + } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index 267f99b5d01..768a357f8a7 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -1,3 +1,4 @@ +import type { ChatHubConversationModel } from '@n8n/api-types'; import type { INodeCredentials } from 'n8n-workflow'; export interface ChatPayloadWithCredentials { @@ -6,7 +7,6 @@ export interface ChatPayloadWithCredentials { messageId: string; sessionId: string; replyId: string; - provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi'; - model: string; + model: ChatHubConversationModel; credentials: INodeCredentials; } diff --git a/packages/cli/src/modules/chat-hub/dto/ask-ai-with-credentials-request.dto.ts b/packages/cli/src/modules/chat-hub/dto/ask-ai-with-credentials-request.dto.ts deleted file mode 100644 index 126581140d2..00000000000 --- a/packages/cli/src/modules/chat-hub/dto/ask-ai-with-credentials-request.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod'; -import { Z } from 'zod-class'; - -export const NodeCredentialsDetailsSchema = z.object({ - id: z.string(), - name: z.string(), -}); - -export const SupportedNodeTypesSchema = z.enum(['@n8n/n8n-nodes-langchain.lmChatOpenAi']); - -export class AskAiWithCredentialsRequestDto extends Z.class({ - messageId: z.string().uuid(), - sessionId: z.string().uuid(), - message: z.string(), - provider: SupportedNodeTypesSchema, - model: z.string(), - credentials: z.record(NodeCredentialsDetailsSchema), -}) {} diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 40cbd20db65..a9cc765900e 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -508,6 +508,7 @@ export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS = 'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS'; export const LOCAL_STORAGE_RUN_DATA_WORKER = 'N8N_RUN_DATA_WORKER'; export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = 'N8N_CHAT_HUB_SELECTED_MODEL'; +export const LOCAL_STORAGE_CHAT_HUB_CREDENTIALS = 'N8N_CHAT_HUB_CREDENTIALS'; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const COMMUNITY_PLUS_DOCS_URL = diff --git a/packages/frontend/editor-ui/src/features/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/chatHub/ChatView.vue index 2e80d2516e2..64270b9c1cd 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/chatHub/ChatView.vue @@ -6,12 +6,18 @@ import hljs from 'highlight.js/lib/core'; import { N8nHeading, N8nIcon, N8nText, N8nScrollArea } from '@n8n/design-system'; import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; import ModelSelector from './components/ModelSelector.vue'; +import CredentialSelectorModal from './components/CredentialSelectorModal.vue'; import { useChatStore } from './chat.store'; import { useUsersStore } from '@/stores/users.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useUIStore } from '@/stores/ui.store'; -import type { ChatMessage, Suggestion } from './chat.types'; +import { + credentialsMapSchema, + type ChatMessage, + type CredentialsMap, + type Suggestion, +} from './chat.types'; import { chatHubConversationModelSchema, type ChatHubProvider, @@ -23,41 +29,23 @@ import VueMarkdown from 'vue-markdown-render'; import markdownLink from 'markdown-it-link-attributes'; import type MarkdownIt from 'markdown-it'; import { useLocalStorage } from '@vueuse/core'; -import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL } from '@/constants'; +import { + LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL, + LOCAL_STORAGE_CHAT_HUB_CREDENTIALS, +} from '@/constants'; import { SUGGESTIONS } from '@/features/chatHub/constants'; -import { isSameModel } from '@/features/chatHub/chat.utils'; +import { findOneFromModelsResponse, modelsResponseContains } from '@/features/chatHub/chat.utils'; const chatStore = useChatStore(); const userStore = useUsersStore(); const credentialsStore = useCredentialsStore(); const uiStore = useUIStore(); -onMounted(async () => { - await Promise.all([ - credentialsStore.fetchCredentialTypes(false), - credentialsStore.fetchAllCredentials(), - ]); - - const models = await chatStore.fetchChatModels( - Object.fromEntries( - chatHubProviderSchema.options.map((provider) => [ - provider, - credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[provider])[0]?.id ?? - null, - ]), - ) as Record, - ); - const selected = selectedModel.value; - - if (selected === null || models.every((model) => !isSameModel(model, selected))) { - selectedModel.value = models[0] ?? null; - } -}); - const message = ref(''); const sessionId = ref(uuidv4()); const messagesRef = ref(null); const scrollAreaRef = ref>(); +const credentialSelectorProvider = ref(null); const selectedModel = useLocalStorage( LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL, null, @@ -67,8 +55,7 @@ const selectedModel = useLocalStorage( serializer: { read: (value) => { try { - const result = chatHubConversationModelSchema.parse(JSON.parse(value)); - return result; + return chatHubConversationModelSchema.parse(JSON.parse(value)); } catch (error) { return null; } @@ -78,6 +65,43 @@ const selectedModel = useLocalStorage( }, ); +const selectedCredentials = useLocalStorage( + LOCAL_STORAGE_CHAT_HUB_CREDENTIALS, + {}, + { + writeDefaults: false, + shallow: true, + serializer: { + read: (value) => { + try { + return credentialsMapSchema.parse(JSON.parse(value)); + } catch (error) { + return {}; + } + }, + write: (value) => JSON.stringify(value), + }, + }, +); + +const autoSelectCredentials = computed(() => + Object.fromEntries( + chatHubProviderSchema.options.map((provider) => { + const lastCreatedCredential = + credentialsStore + .getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[provider]) + .toSorted((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0]?.id ?? null; + + return [provider, lastCreatedCredential]; + }), + ), +); + +const mergedCredentials = computed(() => ({ + ...autoSelectCredentials.value, + ...selectedCredentials.value, +})); + const hasMessages = computed(() => chatStore.chatMessages.length > 0); const inputPlaceholder = computed(() => { if (!selectedModel.value) { @@ -122,11 +146,49 @@ watch( { immediate: true, deep: true }, ); +// TODO: fix duplicate requests +watch( + mergedCredentials, + async (credentials) => { + const models = await chatStore.fetchChatModels(credentials); + const selected = selectedModel.value; + + if (selected === null || !modelsResponseContains(models, selected)) { + selectedModel.value = findOneFromModelsResponse(models) ?? null; + } + }, + { immediate: true }, +); + +onMounted(async () => { + await Promise.all([ + credentialsStore.fetchCredentialTypes(false), + credentialsStore.fetchAllCredentials(), + ]); +}); + function onModelChange(selection: ChatHubConversationModel) { selectedModel.value = selection; } function onConfigure(provider: ChatHubProvider) { + const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider]; + const existingCredentials = credentialsStore.getCredentialsByType(credentialType); + + if (existingCredentials.length === 0) { + uiStore.openNewCredential(credentialType); + return; + } + + credentialSelectorProvider.value = provider; + uiStore.openModal('chatCredentialSelector'); +} + +function onCredentialSelected(provider: ChatHubProvider, credentialId: string) { + selectedCredentials.value = { ...selectedCredentials.value, [provider]: credentialId }; +} + +function onCreateNewCredential(provider: ChatHubProvider) { uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]); } @@ -135,7 +197,19 @@ function onSubmit() { return; } - chatStore.askAI(message.value, sessionId.value, selectedModel.value); + const credentialsId = mergedCredentials.value[selectedModel.value.provider]; + + if (!credentialsId) { + return; + } + + chatStore.askAI(message.value, sessionId.value, selectedModel.value, { + [PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: { + id: credentialsId, + name: '', + }, + }); + message.value = ''; } @@ -177,12 +251,20 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => { +
+import { ref, computed } from 'vue'; +import { N8nButton, N8nOption, N8nSelect, N8nText } from '@n8n/design-system'; +import Modal from '@/components/Modal.vue'; +import { useCredentialsStore } from '@/stores/credentials.store'; +import type { ICredentialsResponse } from '@/Interface'; +import { createEventBus } from '@n8n/utils/event-bus'; +import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider } from '@n8n/api-types'; +import { providerDisplayNames } from '@/features/chatHub/constants'; + +const props = defineProps<{ + provider: ChatHubProvider; + initialValue: string | null; +}>(); + +const emit = defineEmits<{ + select: [provider: ChatHubProvider, credentialId: string]; + createNew: [provider: ChatHubProvider]; +}>(); + +const credentialsStore = useCredentialsStore(); +const modalBus = ref(createEventBus()); +const selectedCredentialId = ref(props.initialValue); + +const availableCredentials = computed(() => { + return credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[props.provider]); +}); + +function onCredentialSelect(credentialId: string) { + selectedCredentialId.value = credentialId; +} + +function onConfirm() { + if (selectedCredentialId.value) { + emit('select', props.provider, selectedCredentialId.value); + modalBus.value.emit('close'); + } +} + +function onCreateNew() { + emit('createNew', props.provider); + modalBus.value.emit('close'); +} + +function onCancel() { + modalBus.value.emit('close'); +} + + + + + diff --git a/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue b/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue index dfa1d6c6d94..a666ae63d3e 100644 --- a/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/chatHub/components/ModelSelector.vue @@ -1,13 +1,18 @@