mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 09:47:00 +02:00
Merge branch 'master' into AI-1523-spacing
This commit is contained in:
commit
bf4ae9dca8
|
|
@ -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(),
|
||||
}),
|
||||
),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
chatModelsRequestSchema,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
ChatHubSendMessageRequest,
|
||||
} from './chat-hub';
|
||||
|
||||
export type { Collaborator } from './push/collaboration';
|
||||
|
|
|
|||
|
|
@ -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))!;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
@ -29,7 +29,7 @@ put:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/variable.yml'
|
||||
$ref: '../schemas/variable.create.yml'
|
||||
required: true
|
||||
responses:
|
||||
'204':
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,3 +15,5 @@ properties:
|
|||
type:
|
||||
type: string
|
||||
readOnly: true
|
||||
project:
|
||||
$ref: '../../../projects/spec/schemas/project.yml'
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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, {}>;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user