Merge branch 'master' into AI-1523-spacing

This commit is contained in:
Mutasem Aldmour 2025-10-09 16:17:44 +02:00 committed by GitHub
commit bf4ae9dca8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 721 additions and 298 deletions

View File

@ -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<typeof chatModelsRequestSchema>;
/**
* 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(),
}),
),
}) {}

View File

@ -15,6 +15,7 @@ export {
chatModelsRequestSchema,
type ChatModelsRequest,
type ChatModelsResponse,
ChatHubSendMessageRequest,
} from './chat-hub';
export type { Collaborator } from './push/collaboration';

View File

@ -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))!;

View File

@ -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<ChatModelsResponse> {
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,

View File

@ -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<ChatHubProvider, string | null>,
): Promise<ChatModelsResponse> {
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<ChatModelsResponse>(
(acc, [provider, res]) => {
acc[provider] = res;
return acc;
},
{
openai: { models: [] },
anthropic: { models: [] },
google: { models: [] },
},
);
}
private async fetchModelsForProvider(
provider: ChatHubProvider,
apiKey: string,
): Promise<ChatHubConversationModel[]> {
): Promise<ChatModelsResponse[ChatHubProvider]> {
switch (provider) {
case 'openai':
return await this.fetchOpenAiModels(apiKey);
@ -110,127 +126,88 @@ export class ChatHubService {
}
}
private async fetchOpenAiModels(apiKey: string): Promise<ChatHubConversationModel[]> {
try {
const response = await fetch('https://api.openai.com/v1/models', {
private async fetchOpenAiModels(apiKey: string): Promise<ChatModelsResponse[ChatHubProvider]> {
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<ChatModelsResponse[ChatHubProvider]> {
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<ChatModelsResponse[ChatHubProvider]> {
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<ChatHubConversationModel[]> {
// 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<ChatHubConversationModel[]> {
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,
};
}
}
}

View File

@ -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;
}

View File

@ -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),
}) {}

View File

@ -29,7 +29,7 @@ put:
content:
application/json:
schema:
$ref: '../schemas/variable.yml'
$ref: '../schemas/variable.create.yml'
required: true
responses:
'204':

View File

@ -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.

View File

@ -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

View File

@ -15,3 +15,5 @@ properties:
type:
type: string
readOnly: true
project:
$ref: '../../../projects/spec/schemas/project.yml'

View File

@ -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({

View File

@ -235,7 +235,19 @@ export declare namespace LicenseRequest {
export declare namespace VariablesRequest {
type CreateUpdatePayload = Omit<Variables, 'id'> & { 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, {}>;

View File

@ -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)

View File

@ -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) {

View File

@ -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', () => {

View File

@ -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 =

View File

@ -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<ChatHubProvider, string | null>,
);
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<HTMLDivElement | null>(null);
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
const credentialSelectorProvider = ref<ChatHubProvider | null>(null);
const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
null,
@ -67,8 +55,7 @@ const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
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<ChatHubConversationModel | null>(
},
);
const selectedCredentials = useLocalStorage<CredentialsMap>(
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<CredentialsMap>(() =>
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) => {
<PageViewLayout>
<ModelSelector
:class="$style.modelSelector"
:models="chatStore.models"
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:disabled="chatStore.isResponding"
@change="onModelChange"
@configure="onConfigure"
/>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
:key="credentialSelectorProvider"
:provider="credentialSelectorProvider"
:initial-value="mergedCredentials[credentialSelectorProvider] ?? null"
@select="onCredentialSelected"
@create-new="onCreateNewCredential"
/>
<div
:class="{
[$style.content]: true,

View File

@ -1,8 +1,11 @@
import { makeRestApiRequest, streamRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { ChatModelsRequest, ChatModelsResponse } from '@n8n/api-types';
import type {
ChatHubSendMessageRequest,
ChatModelsRequest,
ChatModelsResponse,
} from '@n8n/api-types';
import type { StructuredChunk } from './chat.types';
import type { INodeCredentials } from 'n8n-workflow';
export const fetchChatModelsApi = async (
context: IRestApiContext,
@ -14,14 +17,7 @@ export const fetchChatModelsApi = async (
export const sendText = (
ctx: IRestApiContext,
payload: {
message: string;
provider: string;
model: string;
messageId: string;
sessionId: string;
credentials: INodeCredentials;
},
payload: ChatHubSendMessageRequest,
onMessageUpdated: (data: StructuredChunk) => void,
onDone: () => void,
onError: (e: Error) => void,

View File

@ -4,12 +4,16 @@ import { computed, ref } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { fetchChatModelsApi, sendText } from './chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { ChatHubProvider } from '@n8n/api-types';
import type { StructuredChunk, ChatMessage, ChatHubConversationModel } from './chat.types';
import type {
ChatHubConversationModel,
ChatHubSendMessageRequest,
ChatModelsResponse,
} from '@n8n/api-types';
import type { StructuredChunk, ChatMessage, CredentialsMap } from './chat.types';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const models = ref<ChatHubConversationModel[]>([]);
const models = ref<ChatModelsResponse>();
const loadingModels = ref(false);
const isResponding = ref(false);
const chatMessages = ref<ChatMessage[]>([]);
@ -19,11 +23,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
);
const usersMessages = computed(() => chatMessages.value.filter((msg) => msg.role === 'user'));
function setModels(newModels: ChatHubConversationModel[]) {
models.value = newModels;
}
async function fetchChatModels(credentialMap: Record<ChatHubProvider, string | null>) {
async function fetchChatModels(credentialMap: CredentialsMap) {
loadingModels.value = true;
models.value = await fetchChatModelsApi(rootStore.restApiContext, {
credentials: credentialMap,
@ -104,21 +104,23 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
isResponding.value = false;
}
const askAI = (message: string, sessionId: string, model: ChatHubConversationModel) => {
const askAI = (
message: string,
sessionId: string,
model: ChatHubConversationModel,
credentials: ChatHubSendMessageRequest['credentials'],
) => {
const messageId = uuidv4();
addUserMessage(message, messageId);
sendText(
rootStore.restApiContext,
{
model: model.model,
provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
model,
messageId,
sessionId,
message,
credentials: {
openAiApi: { id: 'Jtx6ADZkQZARdxae', name: 'OpenAi account' },
},
credentials,
},
(chunk: StructuredChunk) => onStreamMessage(chunk, messageId),
onStreamDone,
@ -129,7 +131,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
return {
models,
loadingModels,
setModels,
fetchChatModels,
askAI,
isResponding,

View File

@ -1,4 +1,5 @@
export type { ChatHubConversationModel } from '@n8n/api-types';
import { chatHubProviderSchema } from '@n8n/api-types';
import { z } from 'zod';
export interface UserMessage {
id: string;
@ -54,3 +55,7 @@ export interface NodeStreamingState {
isActive: boolean;
startTime: number;
}
export const credentialsMapSchema = z.record(chatHubProviderSchema, z.string().or(z.null()));
export type CredentialsMap = z.infer<typeof credentialsMapSchema>;

View File

@ -1,8 +1,26 @@
import type { ChatHubConversationModel } from '@n8n/api-types';
import {
chatHubProviderSchema,
type ChatHubConversationModel,
type ChatModelsResponse,
} from '@n8n/api-types';
export function isSameModel(
one: ChatHubConversationModel,
another: ChatHubConversationModel,
): boolean {
return one.model === another.model && one.provider === another.provider;
export function modelsResponseContains(
response: ChatModelsResponse,
model: ChatHubConversationModel,
) {
return chatHubProviderSchema.options.some((provider) =>
response[provider].models.some((m) => m.name === model.model),
);
}
export function findOneFromModelsResponse(
response: ChatModelsResponse,
): ChatHubConversationModel | undefined {
for (const provider of chatHubProviderSchema.options) {
if (response[provider].models.length > 0) {
return { model: response[provider].models[0].name, provider };
}
}
return undefined;
}

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
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<string | null>(props.initialValue);
const availableCredentials = computed<ICredentialsResponse[]>(() => {
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');
}
</script>
<template>
<Modal
name="chatCredentialSelector"
:event-bus="modalBus"
width="50%"
:center="true"
max-width="460px"
min-height="250px"
>
<template #header>
<h2 :class="$style.title">Select {{ providerDisplayNames[provider] }} Credential</h2>
</template>
<template #content>
<div :class="$style.content">
<N8nText size="small" color="text-base">
Choose an existing credential or create a new one
</N8nText>
<N8nSelect
:model-value="selectedCredentialId"
size="large"
placeholder="Select credential..."
data-test-id="credential-select"
@update:model-value="onCredentialSelect"
>
<N8nOption
v-for="credential in availableCredentials"
:key="credential.id"
:value="credential.id"
:label="credential.name"
/>
</N8nSelect>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="secondary" @click="onCreateNew"> Create New </N8nButton>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel"> Cancel </N8nButton>
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
Select
</N8nButton>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.title {
font-size: var(--font-size-l);
line-height: var(--font-line-height-regular);
margin: 0;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-s) 0;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.footerRight {
display: flex;
gap: var(--spacing-2xs);
}
</style>

View File

@ -1,13 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton } from '@n8n/design-system';
import type { ChatHubConversationModel } from '../chat.types';
import { type ComponentProps } from 'vue-component-type-helpers';
import { type ChatHubProvider, chatHubProviderSchema } from '@n8n/api-types';
import {
type ChatHubConversationModel,
type ChatHubProvider,
chatHubProviderSchema,
type ChatModelsResponse,
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/chatHub/constants';
const props = defineProps<{
disabled?: boolean;
models: ChatHubConversationModel[];
models: ChatModelsResponse | null;
selectedModel: ChatHubConversationModel | null;
}>();
@ -16,21 +21,20 @@ const emit = defineEmits<{
configure: [ChatHubProvider];
}>();
const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'Open AI',
anthropic: 'Anthropic',
google: 'Google',
};
const menu = computed(() =>
chatHubProviderSchema.options.map((provider) => {
const modelOptions = props.models
.filter((model) => model.provider === provider)
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => ({
id: `${model.provider}::${model.model}`,
title: model.model,
disabled: false,
}));
const models = props.models?.[provider].models ?? [];
const error = props.models?.[provider].error;
const modelOptions =
models.length > 0
? models.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((model) => ({
id: `${provider}::${model.name}`,
title: model.name,
disabled: false,
}))
: error
? [{ id: `${provider}::error`, disabled: true, title: error }]
: [];
return {
id: provider,
@ -57,25 +61,21 @@ function onSelect(id: string) {
const [provider, model] = id.split('::');
const parsedProvider = chatHubProviderSchema.safeParse(provider).data;
if (model === 'configure' && parsedProvider) {
if (!parsedProvider) {
return;
}
if (model === 'configure') {
emit('configure', parsedProvider);
return;
}
const selectedModel = props.models.find((m) => m.provider === provider && m.model === model);
if (selectedModel) {
emit('change', selectedModel);
}
emit('change', { provider: parsedProvider, model });
}
</script>
<template>
<N8nNavigationDropdown
:menu="menu"
:disabled="disabled || models.length === 0"
@select="onSelect"
>
<N8nNavigationDropdown :menu="menu" :disabled="disabled" @select="onSelect">
<N8nButton :class="$style.dropdownButton" type="secondary">
<span>{{ selectedLabel }}</span>
<N8nIcon icon="chevron-down" size="small" />

View File

@ -1,3 +1,4 @@
import type { ChatHubProvider } from '@n8n/api-types';
import type { Suggestion } from './chat.types';
// Route and view identifiers
@ -27,3 +28,9 @@ export const SUGGESTIONS: Suggestion[] = [
icon: '✉️',
},
];
export const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
};