mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 07:17:04 +02:00
feat(core): Chat hub implementation continued (no-changelog) (#20567)
Co-authored-by: Suguru Inoue <suguru@n8n.io>
This commit is contained in:
parent
57e5ac536e
commit
fc63dd7559
42
packages/@n8n/api-types/src/chat-hub.ts
Normal file
42
packages/@n8n/api-types/src/chat-hub.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Supported AI model providers
|
||||
*/
|
||||
export const chatHubProviderSchema = z.enum(['openai', 'anthropic', 'google']);
|
||||
|
||||
export type ChatHubProvider = z.infer<typeof chatHubProviderSchema>;
|
||||
|
||||
/**
|
||||
* Map of providers to their credential types
|
||||
*/
|
||||
export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubProvider, string> = {
|
||||
openai: 'openAiApi',
|
||||
anthropic: 'anthropicApi',
|
||||
google: 'googlePalmApi',
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat Hub conversation model configuration
|
||||
*/
|
||||
export const chatHubConversationModelSchema = z.object({
|
||||
provider: chatHubProviderSchema,
|
||||
model: z.string(),
|
||||
});
|
||||
|
||||
export type ChatHubConversationModel = z.infer<typeof chatHubConversationModelSchema>;
|
||||
|
||||
/**
|
||||
* Request schema for fetching available chat models
|
||||
* Maps provider names to credential IDs (null if no credential available)
|
||||
*/
|
||||
export const chatModelsRequestSchema = z.object({
|
||||
credentials: z.record(chatHubProviderSchema, z.string().nullable()),
|
||||
});
|
||||
|
||||
export type ChatModelsRequest = z.infer<typeof chatModelsRequestSchema>;
|
||||
|
||||
/**
|
||||
* Response type for fetching available chat models
|
||||
*/
|
||||
export type ChatModelsResponse = ChatHubConversationModel[];
|
||||
|
|
@ -6,6 +6,16 @@ export type * from './frontend-settings';
|
|||
export type * from './user';
|
||||
export type * from './api-keys';
|
||||
export type * from './community-node-types';
|
||||
export {
|
||||
chatHubConversationModelSchema,
|
||||
type ChatHubConversationModel,
|
||||
chatHubProviderSchema,
|
||||
type ChatHubProvider,
|
||||
PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
chatModelsRequestSchema,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
} from './chat-hub';
|
||||
|
||||
export type { Collaborator } from './push/collaboration';
|
||||
export type { HeartbeatMessage } from './push/heartbeat';
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
|||
"mcpApiKey:rotate",
|
||||
"mcpApiKey:*",
|
||||
"chatHub:manage",
|
||||
"chatHub:message",
|
||||
"chatHub:*",
|
||||
"*",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const RESOURCES = {
|
|||
role: ['manage'] as const,
|
||||
mcp: ['manage'] as const,
|
||||
mcpApiKey: ['create', 'rotate'] as const,
|
||||
chatHub: ['manage'] as const,
|
||||
chatHub: ['manage', 'message'] as const,
|
||||
} as const;
|
||||
|
||||
export const API_KEY_RESOURCES = {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'mcpApiKey:create',
|
||||
'mcpApiKey:rotate',
|
||||
'chatHub:manage',
|
||||
'chatHub:message',
|
||||
];
|
||||
|
||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||
|
|
@ -117,4 +118,5 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
|
|||
'dataTable:list',
|
||||
'mcpApiKey:create',
|
||||
'mcpApiKey:rotate',
|
||||
'chatHub:message',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -132,3 +132,5 @@ export const WsStatusCodes = {
|
|||
} as const;
|
||||
|
||||
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
|
||||
|
||||
export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, STREAM_SEPARATOR } from '@/constants';
|
||||
import type { CreateCredentialDto } from '@n8n/api-types';
|
||||
import {
|
||||
AiChatRequestDto,
|
||||
|
|
@ -15,7 +16,6 @@ import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
|||
import { strict as assert } from 'node:assert';
|
||||
import { WritableStream } from 'node:stream/web';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error';
|
||||
|
|
@ -75,7 +75,7 @@ export class AiController {
|
|||
// Handle the stream
|
||||
for await (const chunk of aiResponse) {
|
||||
res.flush();
|
||||
res.write(JSON.stringify(chunk) + '⧉⇋⇋➽⌑⧉§§\n');
|
||||
res.write(JSON.stringify(chunk) + STREAM_SEPARATOR);
|
||||
}
|
||||
} catch (streamError) {
|
||||
// If an error occurs during streaming, send it as part of the stream
|
||||
|
|
@ -92,7 +92,7 @@ export class AiController {
|
|||
},
|
||||
],
|
||||
};
|
||||
res.write(JSON.stringify(errorChunk) + '⧉⇋⇋➽⌑⧉§§\n');
|
||||
res.write(JSON.stringify(errorChunk) + STREAM_SEPARATOR);
|
||||
} finally {
|
||||
// Clean up event listener
|
||||
res.off('close', handleClose);
|
||||
|
|
|
|||
|
|
@ -1,80 +1,69 @@
|
|||
import type { ChatModelsResponse } from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { RestController, Get, Post, Body } from '@n8n/decorators';
|
||||
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 { ChatMessageRequestDto } from './dto/chat-message-request.dto';
|
||||
|
||||
export type FlushableResponse = Response & { flush: () => void };
|
||||
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) {}
|
||||
|
||||
@Get('/agents/models/openai')
|
||||
async getModels(_req: AuthenticatedRequest, res: FlushableResponse) {
|
||||
const models = await this.chatService.getModels();
|
||||
res.json(models);
|
||||
@Post('/models')
|
||||
async getModels(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body payload: ChatModelsRequestDto,
|
||||
): Promise<ChatModelsResponse> {
|
||||
return await this.chatService.getModels(req.user.id, payload.credentials);
|
||||
}
|
||||
|
||||
@Post('/agents/openai')
|
||||
async ask(
|
||||
@GlobalScope('chatHub:message')
|
||||
@Post('/send')
|
||||
async sendMessage(
|
||||
req: AuthenticatedRequest,
|
||||
res: FlushableResponse,
|
||||
@Body payload: ChatMessageRequestDto,
|
||||
res: Response,
|
||||
@Body payload: AskAiWithCredentialsRequestDto,
|
||||
) {
|
||||
res.header('Content-type', 'application/json-lines; charset=utf-8');
|
||||
res.header('Transfer-Encoding', 'chunked');
|
||||
res.header('Connection', 'keep-alive');
|
||||
res.header('Cache-Control', 'no-cache');
|
||||
res.flushHeaders();
|
||||
|
||||
// TODO: Save human message to DB
|
||||
|
||||
const replyId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
await this.chatService.askN8n(res, req.user, {
|
||||
...payload,
|
||||
userId: req.user.id,
|
||||
replyId,
|
||||
});
|
||||
} catch (executionError: unknown) {
|
||||
assert(executionError instanceof Error);
|
||||
|
||||
const handleClose = () => abortController.abort();
|
||||
|
||||
res.on('close', handleClose);
|
||||
|
||||
const aiResponse = this.chatService.ask(payload, req.user, signal);
|
||||
|
||||
try {
|
||||
// Handle the stream
|
||||
for await (const chunk of aiResponse) {
|
||||
res.flush();
|
||||
res.write(JSON.stringify(chunk) + '⧉⇋⇋➽⌑⧉§§\n');
|
||||
}
|
||||
} catch (streamError) {
|
||||
// If an error occurs during streaming, send it as part of the stream
|
||||
// This prevents "Cannot set headers after they are sent" error
|
||||
assert(streamError instanceof Error);
|
||||
|
||||
// Send error as proper error type now that frontend supports it
|
||||
const errorChunk = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'error',
|
||||
content: streamError.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
res.write(JSON.stringify(errorChunk) + '⧉⇋⇋➽⌑⧉§§\n');
|
||||
} finally {
|
||||
// Clean up event listener
|
||||
res.off('close', handleClose);
|
||||
}
|
||||
|
||||
res.end();
|
||||
} catch (e: unknown) {
|
||||
// This catch block handles errors that occur before streaming starts
|
||||
// Since headers haven't been sent yet, we can still send a proper error response
|
||||
assert(e instanceof Error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: e.message,
|
||||
message: executionError.message,
|
||||
});
|
||||
} else {
|
||||
// If headers were already sent dont't send a second error response
|
||||
res.end();
|
||||
res.write(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
content: executionError.message,
|
||||
id: replyId,
|
||||
}) + '\n',
|
||||
);
|
||||
res.flush();
|
||||
}
|
||||
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,442 @@
|
|||
import {
|
||||
PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
type ChatHubProvider,
|
||||
type ChatHubConversationModel,
|
||||
type ChatModelsResponse,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
ExecutionRepository,
|
||||
IExecutionResponse,
|
||||
ProjectRepository,
|
||||
SharedWorkflow,
|
||||
SharedWorkflowRepository,
|
||||
User,
|
||||
WorkflowEntity,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { type IUser } from 'n8n-workflow';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
OperationalError,
|
||||
type IConnections,
|
||||
type INode,
|
||||
type INodeCredentialsDetails,
|
||||
type ITaskData,
|
||||
type IWorkflowBase,
|
||||
type StartNodeData,
|
||||
} from 'n8n-workflow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { type ChatPayload } from './chat-hub.types';
|
||||
import type { ChatPayloadWithCredentials } from './chat-hub.types';
|
||||
|
||||
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';
|
||||
|
||||
@Service()
|
||||
export class ChatHubService {
|
||||
constructor() {}
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly credentialsHelper: CredentialsHelper,
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly workflowExecutionService: WorkflowExecutionService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
) {}
|
||||
|
||||
async getModels() {
|
||||
return await Promise.resolve(['gpt-3.5-turbo', 'gpt-4']);
|
||||
async getModels(
|
||||
userId: string,
|
||||
credentialIds: Record<ChatHubProvider, string | null>,
|
||||
): Promise<ChatModelsResponse> {
|
||||
const models: ChatHubConversationModel[] = [];
|
||||
const additionalData = await getBase({ userId });
|
||||
|
||||
for (const [providerKey, credentialId] of Object.entries(credentialIds)) {
|
||||
if (!credentialId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type assertion is safe here because credentialIds type guarantees valid keys
|
||||
const provider = providerKey as ChatHubProvider;
|
||||
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
|
||||
|
||||
try {
|
||||
// Get the decrypted credential data
|
||||
const nodeCredentials: INodeCredentialsDetails = {
|
||||
id: credentialId,
|
||||
name: credentialType,
|
||||
};
|
||||
|
||||
const credentials = await this.credentialsHelper.getDecrypted(
|
||||
additionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'internal',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Extract API key from credentials based on provider
|
||||
const apiKey = this.extractApiKey(provider, credentials);
|
||||
if (!apiKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
async *ask(_payload: ChatPayload, _user: IUser, _abortSignal?: AbortSignal) {
|
||||
yield* [
|
||||
private async fetchModelsForProvider(
|
||||
provider: ChatHubProvider,
|
||||
apiKey: string,
|
||||
): Promise<ChatHubConversationModel[]> {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return await this.fetchOpenAiModels(apiKey);
|
||||
case 'anthropic':
|
||||
return await this.fetchAnthropicModels(apiKey);
|
||||
case 'google':
|
||||
return await this.fetchGoogleModels(apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchOpenAiModels(apiKey: string): Promise<ChatHubConversationModel[]> {
|
||||
try {
|
||||
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();
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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' }],
|
||||
}),
|
||||
});
|
||||
|
||||
// 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 {
|
||||
if (typeof credentials !== 'object' || credentials === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const creds = credentials as Record<string, unknown>;
|
||||
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
case 'anthropic':
|
||||
case 'google':
|
||||
// All providers use 'apiKey' field
|
||||
return typeof creds.apiKey === 'string' ? creds.apiKey : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async createChatWorkflow(
|
||||
user: User,
|
||||
sessionId: string,
|
||||
nodes: INode[],
|
||||
connections: IConnections,
|
||||
) {
|
||||
const { manager } = this.projectRepository;
|
||||
const existing = await this.workflowRepository.findOneBy({ id: sessionId });
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return await manager.transaction(async (trx) => {
|
||||
const project = await this.projectRepository.getPersonalProjectForUser(user.id, trx);
|
||||
if (!project) {
|
||||
throw new NotFoundError('Could not find a personal project for this user');
|
||||
}
|
||||
|
||||
const newWorkflow = new WorkflowEntity();
|
||||
newWorkflow.versionId = uuidv4();
|
||||
newWorkflow.id = sessionId;
|
||||
newWorkflow.name = `Chat ${sessionId}`;
|
||||
newWorkflow.active = false;
|
||||
newWorkflow.nodes = nodes;
|
||||
newWorkflow.connections = connections;
|
||||
|
||||
const workflow = await trx.save<WorkflowEntity>(newWorkflow);
|
||||
|
||||
await trx.save<SharedWorkflow>(
|
||||
this.sharedWorkflowRepository.create({
|
||||
role: 'workflow:owner',
|
||||
projectId: project.id,
|
||||
workflow,
|
||||
}),
|
||||
);
|
||||
|
||||
return workflow;
|
||||
});
|
||||
}
|
||||
|
||||
private getMessage(execution: IExecutionResponse): string | undefined {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
if (typeof lastNodeExecuted !== 'string') return undefined;
|
||||
|
||||
const runIndex = execution.data.resultData.runData[lastNodeExecuted].length - 1;
|
||||
const mainOutputs = execution.data.resultData.runData[lastNodeExecuted][runIndex]?.data?.main;
|
||||
|
||||
// Check all main output branches for a message
|
||||
if (mainOutputs && Array.isArray(mainOutputs)) {
|
||||
for (const branch of mainOutputs) {
|
||||
if (branch && Array.isArray(branch) && branch.length > 0 && branch[0].json?.output) {
|
||||
return branch[0].json.output as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async askN8n(res: Response, user: User, payload: ChatPayloadWithCredentials) {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
message: 'hello world',
|
||||
parameters: {
|
||||
public: true,
|
||||
mode: 'webhook',
|
||||
options: { responseMode: 'streaming' },
|
||||
},
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
id: uuidv4(),
|
||||
name: 'When chat message received',
|
||||
webhookId: uuidv4(),
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
options: {
|
||||
enableStreaming: true,
|
||||
},
|
||||
},
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const connections: IConnections = {
|
||||
'When chat message received': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Chat Model': {
|
||||
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = await this.createChatWorkflow(user, payload.sessionId, nodes, connections);
|
||||
const workflowData: IWorkflowBase = {
|
||||
...workflow,
|
||||
nodes,
|
||||
connections,
|
||||
versionId: uuidv4(),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
const startNodes: StartNodeData[] = [{ name: 'AI Agent', sourceData: null }];
|
||||
const triggerToStartFrom: {
|
||||
name: string;
|
||||
data?: ITaskData;
|
||||
} = {
|
||||
name: 'When chat message received',
|
||||
data: {
|
||||
startTime: Date.now(),
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
executionStatus: 'success',
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
sessionId: payload.sessionId,
|
||||
action: 'sendMessage',
|
||||
chatInput: payload.message,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
source: [null],
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Starting execution of workflow "${workflow.name}" with ID ${workflow.id}`);
|
||||
|
||||
const { executionId } = await this.workflowExecutionService.executeManually(
|
||||
{
|
||||
workflowData,
|
||||
startNodes,
|
||||
triggerToStartFrom,
|
||||
},
|
||||
user,
|
||||
undefined,
|
||||
true,
|
||||
res,
|
||||
);
|
||||
|
||||
if (!executionId) {
|
||||
throw new OperationalError('There was a problem starting the chat execution.');
|
||||
}
|
||||
|
||||
// TODO: The execution finishes after a while, how do we store the full AI response on the database?
|
||||
// Is there a better way to listen for the execution to finish?
|
||||
const onClose = async () => {
|
||||
this.logger.debug(`Connection closed by client, execution ID: ${executionId}`);
|
||||
|
||||
// TODO: we could maybe stop executions here if user disconnected early?
|
||||
// if (execution && ['running', 'waiting'].includes(execution.status)) {
|
||||
// await this.executionService.stop(executionId, [workflow.id]);
|
||||
// }
|
||||
|
||||
const execution = await this.executionRepository.findWithUnflattenedData(executionId, [
|
||||
workflow.id,
|
||||
]);
|
||||
|
||||
// Persist the assistant message to the database
|
||||
if (execution?.data?.resultData) {
|
||||
// resultData is only available if the execution finished
|
||||
const message = this.getMessage(execution);
|
||||
this.logger.debug(`Assistant: ${message} (${payload.replyId})`);
|
||||
}
|
||||
};
|
||||
|
||||
res.on('close', onClose);
|
||||
res.on('error', onClose);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
export interface ChatPayload {
|
||||
import type { INodeCredentials } from 'n8n-workflow';
|
||||
|
||||
export interface ChatPayloadWithCredentials {
|
||||
userId: string;
|
||||
message: string;
|
||||
messageId: string;
|
||||
sessionId: string;
|
||||
replyId: string;
|
||||
provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
model: string;
|
||||
credentials: INodeCredentials;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
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),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { chatModelsRequestSchema } from '@n8n/api-types';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class ChatModelsRequestDto extends Z.class(chatModelsRequestSchema.shape) {}
|
||||
|
|
@ -3,6 +3,7 @@ import { GlobalConfig } from '@n8n/config';
|
|||
import type { Project, User, CreateExecutionPayload } from '@n8n/db';
|
||||
import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import type {
|
||||
IDeferredPromise,
|
||||
|
|
@ -111,6 +112,8 @@ export class WorkflowExecutionService {
|
|||
}: WorkflowRequest.ManualRunPayload,
|
||||
user: User,
|
||||
pushRef?: string,
|
||||
streamingEnabled?: boolean,
|
||||
httpResponse?: Response,
|
||||
) {
|
||||
const pinData = workflowData.pinData;
|
||||
let pinnedTrigger = this.selectPinnedActivatorStarter(
|
||||
|
|
@ -185,6 +188,8 @@ export class WorkflowExecutionService {
|
|||
dirtyNodeNames,
|
||||
triggerToStartFrom,
|
||||
agentRequest,
|
||||
streamingEnabled,
|
||||
httpResponse,
|
||||
};
|
||||
|
||||
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ import IconLucideMaximize2 from '~icons/lucide/maximize-2';
|
|||
import IconLucideMenu from '~icons/lucide/menu';
|
||||
import IconLucideMessageCircle from '~icons/lucide/message-circle';
|
||||
import IconLucideMessagesSquare from '~icons/lucide/messages-square';
|
||||
import IconLucideMic from '~icons/lucide/mic';
|
||||
import IconLucideMilestone from '~icons/lucide/milestone';
|
||||
import IconLucideMinimize2 from '~icons/lucide/minimize-2';
|
||||
import IconLucideMousePointer from '~icons/lucide/mouse-pointer';
|
||||
|
|
@ -153,6 +154,7 @@ import IconLucidePackageOpen from '~icons/lucide/package-open';
|
|||
import IconLucidePalette from '~icons/lucide/palette';
|
||||
import IconLucidePanelLeft from '~icons/lucide/panel-left';
|
||||
import IconLucidePanelRight from '~icons/lucide/panel-right';
|
||||
import IconLucidePaperclip from '~icons/lucide/paperclip';
|
||||
import IconLucidePause from '~icons/lucide/pause';
|
||||
import IconLucidePen from '~icons/lucide/pen';
|
||||
import IconLucidePencil from '~icons/lucide/pencil';
|
||||
|
|
@ -573,6 +575,7 @@ export const updatedIconSet = {
|
|||
menu: IconLucideMenu,
|
||||
'message-circle': IconLucideMessageCircle,
|
||||
'messages-square': IconLucideMessagesSquare,
|
||||
mic: IconLucideMic,
|
||||
milestone: IconLucideMilestone,
|
||||
'mouse-pointer': IconLucideMousePointer,
|
||||
network: IconLucideNetwork,
|
||||
|
|
@ -580,6 +583,7 @@ export const updatedIconSet = {
|
|||
palette: IconLucidePalette,
|
||||
'panel-left': IconLucidePanelLeft,
|
||||
'panel-right': IconLucidePanelRight,
|
||||
paperclip: IconLucidePaperclip,
|
||||
pause: IconLucidePause,
|
||||
pen: IconLucidePen,
|
||||
pencil: IconLucidePencil,
|
||||
|
|
|
|||
|
|
@ -14,18 +14,19 @@ type BaseItem = {
|
|||
disabled?: boolean;
|
||||
icon?: IconName | { type: 'icon'; value: IconName } | { type: 'emoji'; value: string };
|
||||
route?: RouteLocationRaw;
|
||||
isDivider?: false;
|
||||
};
|
||||
|
||||
type Item = BaseItem & {
|
||||
submenu?: BaseItem[];
|
||||
};
|
||||
type Divider = { isDivider: true; id: string };
|
||||
|
||||
type Item = BaseItem & { submenu?: Array<BaseItem | Divider> };
|
||||
|
||||
defineOptions({
|
||||
name: 'N8nNavigationDropdown',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
menu: Item[];
|
||||
menu: Array<Item | Divider>;
|
||||
disabled?: boolean;
|
||||
teleport?: boolean;
|
||||
}>();
|
||||
|
|
@ -84,7 +85,8 @@ defineExpose({
|
|||
</template>
|
||||
|
||||
<template v-for="item in menu" :key="item.id">
|
||||
<template v-if="item.submenu">
|
||||
<hr v-if="item.isDivider" />
|
||||
<template v-else-if="item.submenu">
|
||||
<ElSubMenu
|
||||
:popper-class="$style.nestedSubmenu"
|
||||
:index="item.id"
|
||||
|
|
@ -93,7 +95,8 @@ defineExpose({
|
|||
>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template v-for="subitem in item.submenu" :key="subitem.id">
|
||||
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
|
||||
<hr v-if="subitem.isDivider" />
|
||||
<ConditionalRouterLink v-else :to="(!subitem.disabled && subitem.route) || undefined">
|
||||
<ElMenuItem
|
||||
data-test-id="navigation-submenu-item"
|
||||
:index="subitem.id"
|
||||
|
|
@ -159,6 +162,12 @@ defineExpose({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
& hr {
|
||||
border-top: none;
|
||||
border-bottom: var(--border-base);
|
||||
margin-block: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
|
||||
.nestedSubmenu {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './utils';
|
||||
import { ResponseError, STREAM_SEPARATOR, streamRequest } from './utils';
|
||||
|
||||
describe('streamRequest', () => {
|
||||
it('should stream data from the API endpoint', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const mockResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPARATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPARATOR}`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
|
@ -107,11 +107,11 @@ describe('streamRequest', () => {
|
|||
const mockResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}{"chunk": `),
|
||||
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}{"chunk": `),
|
||||
);
|
||||
controller.enqueue(encoder.encode(`2}${STREAM_SEPERATOR}{"ch`));
|
||||
controller.enqueue(encoder.encode(`2}${STREAM_SEPARATOR}{"ch`));
|
||||
controller.enqueue(encoder.encode('unk":'));
|
||||
controller.enqueue(encoder.encode(`3}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`3}${STREAM_SEPARATOR}`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const getBrowserId = () => {
|
|||
};
|
||||
|
||||
export const NO_NETWORK_ERROR_CODE = 999;
|
||||
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||
export const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||
|
||||
export class MfaRequiredError extends ApplicationError {
|
||||
constructor() {
|
||||
|
|
@ -218,7 +218,7 @@ export async function streamRequest<T extends object>(
|
|||
onChunk?: (chunk: T) => void,
|
||||
onDone?: () => void,
|
||||
onError?: (e: Error) => void,
|
||||
separator = STREAM_SEPERATOR,
|
||||
separator = STREAM_SEPARATOR,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
VIEWS,
|
||||
WHATS_NEW_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { CHAT_VIEW } from '@/features/chatHub/constants';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
|
@ -199,6 +200,16 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
|
|||
settingsStore.isModuleActive('insights') &&
|
||||
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
icon: 'bot',
|
||||
label: 'Chat',
|
||||
position: 'bottom',
|
||||
route: { to: { name: CHAT_VIEW } },
|
||||
available:
|
||||
settingsStore.isChatFeatureEnabled &&
|
||||
hasPermission(['rbac'], { rbac: { scope: 'chatHub:message' } }),
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
icon: 'circle-help',
|
||||
|
|
|
|||
|
|
@ -506,6 +506,7 @@ export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
|
|||
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 BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
|
|
|
|||
605
packages/frontend/editor-ui/src/features/chatHub/ChatView.vue
Normal file
605
packages/frontend/editor-ui/src/features/chatHub/ChatView.vue
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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 { 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 {
|
||||
chatHubConversationModelSchema,
|
||||
type ChatHubProvider,
|
||||
PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
type ChatHubConversationModel,
|
||||
chatHubProviderSchema,
|
||||
} from '@n8n/api-types';
|
||||
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 { SUGGESTIONS } from '@/features/chatHub/constants';
|
||||
import { isSameModel } 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 selectedModel = useLocalStorage<ChatHubConversationModel | null>(
|
||||
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
|
||||
null,
|
||||
{
|
||||
writeDefaults: false,
|
||||
shallow: true,
|
||||
serializer: {
|
||||
read: (value) => {
|
||||
try {
|
||||
const result = chatHubConversationModelSchema.parse(JSON.parse(value));
|
||||
return result;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
write: (value) => JSON.stringify(value),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hasMessages = computed(() => chatStore.chatMessages.length > 0);
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (!selectedModel.value) {
|
||||
return 'Select a model';
|
||||
}
|
||||
|
||||
const modelName = selectedModel.value.model;
|
||||
|
||||
return `Message ${modelName}`;
|
||||
});
|
||||
|
||||
const scrollOnNewMessage = ref(true);
|
||||
|
||||
function getScrollViewport(): HTMLElement | null {
|
||||
const root = scrollAreaRef.value?.$el as HTMLElement | undefined;
|
||||
return root?.querySelector('[data-reka-scroll-area-viewport]') as HTMLElement | null;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const viewport = getScrollViewport();
|
||||
if (viewport && messagesRef.value) {
|
||||
viewport.scrollTo({
|
||||
top: messagesRef.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => chatStore.chatMessages,
|
||||
async (messages) => {
|
||||
// Check if the last message is user and scroll to bottom of the chat
|
||||
if (scrollOnNewMessage.value && messages.length > 0) {
|
||||
// Wait for DOM updates before scrolling
|
||||
await nextTick();
|
||||
// Check if viewport is available after nextTick
|
||||
if (getScrollViewport()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
function onModelChange(selection: ChatHubConversationModel) {
|
||||
selectedModel.value = selection;
|
||||
}
|
||||
|
||||
function onConfigure(provider: ChatHubProvider) {
|
||||
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (!message.value.trim() || chatStore.isResponding || !selectedModel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatStore.askAI(message.value, sessionId.value, selectedModel.value);
|
||||
message.value = '';
|
||||
}
|
||||
|
||||
function onSuggestionClick(s: Suggestion) {
|
||||
message.value = `${s.title} ${s.subtitle}`;
|
||||
}
|
||||
|
||||
function onAttach() {}
|
||||
|
||||
function onMic() {}
|
||||
|
||||
function messageText(msg: ChatMessage) {
|
||||
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
|
||||
}
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return ''; // use external default escaping
|
||||
},
|
||||
};
|
||||
|
||||
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout>
|
||||
<ModelSelector
|
||||
:class="$style.modelSelector"
|
||||
:models="chatStore.models"
|
||||
:selected-model="selectedModel"
|
||||
:disabled="chatStore.isResponding"
|
||||
@change="onModelChange"
|
||||
@configure="onConfigure"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
[$style.content]: true,
|
||||
[$style.centered]: !hasMessages,
|
||||
}"
|
||||
>
|
||||
<section
|
||||
:class="{
|
||||
[$style.section]: true,
|
||||
[$style.fullHeight]: hasMessages,
|
||||
}"
|
||||
>
|
||||
<div v-if="!hasMessages" :class="$style.header">
|
||||
<N8nHeading tag="h2" bold size="xlarge">
|
||||
{{
|
||||
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`
|
||||
}}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasMessages" :class="$style.suggestions">
|
||||
<button
|
||||
v-for="s in SUGGESTIONS"
|
||||
:key="s.title"
|
||||
type="button"
|
||||
:class="$style.card"
|
||||
@click="onSuggestionClick(s)"
|
||||
>
|
||||
<div :class="$style.cardIcon" aria-hidden="true">
|
||||
<N8nText size="xlarge">{{ s.icon }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.cardText">
|
||||
<N8nText bold color="text-dark">{{ s.title }}</N8nText>
|
||||
<N8nText color="text-base">{{ s.subtitle }}</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat thread -->
|
||||
<template v-else>
|
||||
<div :class="$style.threadContainer">
|
||||
<N8nScrollArea
|
||||
ref="scrollAreaRef"
|
||||
type="hover"
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
:class="$style.threadWrap"
|
||||
>
|
||||
<div ref="messagesRef" :class="$style.thread" role="log" aria-live="polite">
|
||||
<div
|
||||
v-for="m in chatStore.chatMessages"
|
||||
:key="m.id"
|
||||
:class="[
|
||||
$style.message,
|
||||
m.role === 'user' ? $style.user : $style.assistant,
|
||||
m.type === 'error' && $style.error,
|
||||
]"
|
||||
>
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon
|
||||
:icon="m.role === 'user' ? 'user' : 'sparkles'"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
[$style.chatMessage]: true,
|
||||
[$style.chatMessageFromUser]: m.role === 'user',
|
||||
[$style.chatMessageFromAssistant]: m.role === 'assistant',
|
||||
}"
|
||||
>
|
||||
<VueMarkdown
|
||||
:class="$style.chatMessageMarkdown"
|
||||
:source="messageText(m)"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator while streaming -->
|
||||
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon icon="sparkles" width="20" height="20" />
|
||||
</div>
|
||||
<div :class="$style.bubble">
|
||||
<span :class="$style.typing"><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Prompt -->
|
||||
<form :class="$style.prompt" @submit.prevent="onSubmit">
|
||||
<div :class="$style.inputWrap">
|
||||
<input
|
||||
v-model="message"
|
||||
:class="$style.input"
|
||||
type="text"
|
||||
:placeholder="inputPlaceholder"
|
||||
autocomplete="off"
|
||||
:disabled="chatStore.isResponding"
|
||||
/>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Attach"
|
||||
:disabled="chatStore.isResponding"
|
||||
@click="onAttach"
|
||||
>
|
||||
<N8nIcon icon="paperclip" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Voice"
|
||||
:disabled="chatStore.isResponding"
|
||||
@click="onMic"
|
||||
>
|
||||
<N8nIcon icon="mic" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
:class="$style.sendBtn"
|
||||
type="submit"
|
||||
:disabled="chatStore.isResponding || !message.trim()"
|
||||
>
|
||||
<span v-if="!chatStore.isResponding">Send</span>
|
||||
<span v-else>…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<N8nText :class="$style.disclaimer" color="text-light" size="small">
|
||||
AI may make mistakes. Check important info.
|
||||
<br />
|
||||
{{ sessionId }}
|
||||
</N8nText>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
padding-bottom: var(--spacing-l);
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Suggestions */
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: var(--spacing-m);
|
||||
width: min(960px, 90%);
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.suggestions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m);
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
background: var(--color-background-base);
|
||||
border-radius: var(--border-radius-large);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.06s ease,
|
||||
background 0.06s ease,
|
||||
border-color 0.06s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
}
|
||||
.cardIcon {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.cardText {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.threadWrap {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.threadContainer {
|
||||
width: min(960px, 90%);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.thread {
|
||||
padding: var(--spacing-m);
|
||||
background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-background-xlight);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.chatMessage {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
padding: var(--spacing-m);
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
&.chatMessageFromAssistant {
|
||||
background-color: var(--color-background-base);
|
||||
}
|
||||
|
||||
&.chatMessageFromUser {
|
||||
background-color: var(--color-background-medium);
|
||||
}
|
||||
|
||||
> .chatMessageMarkdown {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
font-size: inherit;
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
padding: var(--chat--spacing);
|
||||
background: var(--chat--message--pre--background);
|
||||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.typing i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.typing i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt */
|
||||
.prompt {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(720px, 50vw);
|
||||
min-width: 320px;
|
||||
|
||||
& input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
padding: 14px 112px 14px 14px;
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
background: var(--color-background-light);
|
||||
color: var(--color-text-dark);
|
||||
border-radius: 16px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Right-side actions */
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.iconBtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
.sendBtn {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sendBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: var(--spacing-xs);
|
||||
color: var(--color-text-lighter);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modelSelector {
|
||||
position: absolute;
|
||||
top: var(--spacing-s);
|
||||
left: var(--spacing-s);
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
38
packages/frontend/editor-ui/src/features/chatHub/chat.api.ts
Normal file
38
packages/frontend/editor-ui/src/features/chatHub/chat.api.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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 { StructuredChunk } from './chat.types';
|
||||
import type { INodeCredentials } from 'n8n-workflow';
|
||||
|
||||
export const fetchChatModelsApi = async (
|
||||
context: IRestApiContext,
|
||||
payload: ChatModelsRequest,
|
||||
): Promise<ChatModelsResponse> => {
|
||||
const apiEndpoint = '/chat/models';
|
||||
return await makeRestApiRequest<ChatModelsResponse>(context, 'POST', apiEndpoint, payload);
|
||||
};
|
||||
|
||||
export const sendText = (
|
||||
ctx: IRestApiContext,
|
||||
payload: {
|
||||
message: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
messageId: string;
|
||||
sessionId: string;
|
||||
credentials: INodeCredentials;
|
||||
},
|
||||
onMessageUpdated: (data: StructuredChunk) => void,
|
||||
onDone: () => void,
|
||||
onError: (e: Error) => void,
|
||||
): void => {
|
||||
void streamRequest<StructuredChunk>(
|
||||
ctx,
|
||||
'/chat/send',
|
||||
payload,
|
||||
onMessageUpdated,
|
||||
onDone,
|
||||
onError,
|
||||
'\n',
|
||||
);
|
||||
};
|
||||
141
packages/frontend/editor-ui/src/features/chatHub/chat.store.ts
Normal file
141
packages/frontend/editor-ui/src/features/chatHub/chat.store.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { CHAT_STORE } from './constants';
|
||||
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';
|
||||
|
||||
export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
const rootStore = useRootStore();
|
||||
const models = ref<ChatHubConversationModel[]>([]);
|
||||
const loadingModels = ref(false);
|
||||
const isResponding = ref(false);
|
||||
const chatMessages = ref<ChatMessage[]>([]);
|
||||
|
||||
const assistantMessages = computed(() =>
|
||||
chatMessages.value.filter((msg) => msg.role === 'assistant'),
|
||||
);
|
||||
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>) {
|
||||
loadingModels.value = true;
|
||||
models.value = await fetchChatModelsApi(rootStore.restApiContext, {
|
||||
credentials: credentialMap,
|
||||
});
|
||||
loadingModels.value = false;
|
||||
return models.value;
|
||||
}
|
||||
|
||||
function addUserMessage(content: string, id: string) {
|
||||
chatMessages.value.push({
|
||||
id,
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: content,
|
||||
});
|
||||
}
|
||||
|
||||
function addAiMessage(content: string, id: string) {
|
||||
chatMessages.value.push({
|
||||
id,
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: content,
|
||||
});
|
||||
}
|
||||
|
||||
function appendMessage(content: string, id: string) {
|
||||
const existingMessage = chatMessages.value.find((m) => m.id === id);
|
||||
if (existingMessage && existingMessage.type === 'message') {
|
||||
existingMessage.text += content;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onBeginMessage(messageId: string, nodeId: string, runIndex?: number) {
|
||||
isResponding.value = true;
|
||||
addAiMessage('', `${messageId}-${nodeId}-${runIndex ?? 0}`);
|
||||
}
|
||||
|
||||
function onChunk(messageId: string, chunk: string, nodeId?: string, runIndex?: number) {
|
||||
appendMessage(chunk, `${messageId}-${nodeId}-${runIndex ?? 0}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function onEndMessage(_messageId: string, _nodeId: string, _runIndex?: number) {
|
||||
isResponding.value = false;
|
||||
}
|
||||
|
||||
function onStreamMessage(message: StructuredChunk, messageId: string) {
|
||||
const nodeId = message.metadata?.nodeId || 'unknown';
|
||||
const runIndex = message.metadata?.runIndex;
|
||||
|
||||
switch (message.type) {
|
||||
case 'begin':
|
||||
onBeginMessage(messageId, nodeId, runIndex);
|
||||
break;
|
||||
case 'item':
|
||||
onChunk(messageId, message.content ?? '', nodeId, runIndex);
|
||||
break;
|
||||
case 'end':
|
||||
onEndMessage(messageId, nodeId, runIndex);
|
||||
break;
|
||||
case 'error':
|
||||
onChunk(messageId, `Error: ${message.content ?? 'Unknown error'}`, nodeId, runIndex);
|
||||
onEndMessage(messageId, nodeId, runIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// addAssistantMessages(response.messages);
|
||||
}
|
||||
|
||||
function onStreamDone() {
|
||||
isResponding.value = false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function onStreamError(_e: Error) {
|
||||
isResponding.value = false;
|
||||
}
|
||||
|
||||
const askAI = (message: string, sessionId: string, model: ChatHubConversationModel) => {
|
||||
const messageId = uuidv4();
|
||||
addUserMessage(message, messageId);
|
||||
|
||||
sendText(
|
||||
rootStore.restApiContext,
|
||||
{
|
||||
model: model.model,
|
||||
provider: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
messageId,
|
||||
sessionId,
|
||||
message,
|
||||
credentials: {
|
||||
openAiApi: { id: 'Jtx6ADZkQZARdxae', name: 'OpenAi account' },
|
||||
},
|
||||
},
|
||||
(chunk: StructuredChunk) => onStreamMessage(chunk, messageId),
|
||||
onStreamDone,
|
||||
onStreamError,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
models,
|
||||
loadingModels,
|
||||
setModels,
|
||||
fetchChatModels,
|
||||
askAI,
|
||||
isResponding,
|
||||
chatMessages,
|
||||
assistantMessages,
|
||||
usersMessages,
|
||||
addUserMessage,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
export type { ChatHubConversationModel } from '@n8n/api-types';
|
||||
|
||||
export interface UserMessage {
|
||||
id: string;
|
||||
role: 'user';
|
||||
type: 'message';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
type: 'message';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
type: 'error';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type StreamChunk = AssistantMessage | ErrorMessage;
|
||||
export type ChatMessage = UserMessage | AssistantMessage | ErrorMessage;
|
||||
|
||||
export interface StreamOutput {
|
||||
messages: StreamChunk[];
|
||||
}
|
||||
|
||||
export type Suggestion = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
// From @n8n/chat
|
||||
export type ChunkType = 'begin' | 'item' | 'end' | 'error';
|
||||
export interface StructuredChunk {
|
||||
type: ChunkType;
|
||||
content?: string;
|
||||
metadata: {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
timestamp: number;
|
||||
runIndex: number;
|
||||
itemIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeStreamingState {
|
||||
nodeId: string;
|
||||
chunks: string[];
|
||||
isActive: boolean;
|
||||
startTime: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { ChatHubConversationModel } from '@n8n/api-types';
|
||||
|
||||
export function isSameModel(
|
||||
one: ChatHubConversationModel,
|
||||
another: ChatHubConversationModel,
|
||||
): boolean {
|
||||
return one.model === another.model && one.provider === another.provider;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<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';
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
models: ChatHubConversationModel[];
|
||||
selectedModel: ChatHubConversationModel | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [ChatHubConversationModel];
|
||||
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,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: provider,
|
||||
title: providerDisplayNames[provider],
|
||||
submenu: modelOptions.concat([
|
||||
...(modelOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []),
|
||||
{
|
||||
id: `${provider}::configure`,
|
||||
title: 'Configure credentials...',
|
||||
disabled: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!props.selectedModel) return 'Select model';
|
||||
return props.selectedModel.model;
|
||||
});
|
||||
|
||||
function onSelect(id: string) {
|
||||
// Format is "provider::model"
|
||||
const [provider, model] = id.split('::');
|
||||
const parsedProvider = chatHubProviderSchema.safeParse(provider).data;
|
||||
|
||||
if (model === 'configure' && parsedProvider) {
|
||||
emit('configure', parsedProvider);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModel = props.models.find((m) => m.provider === provider && m.model === model);
|
||||
|
||||
if (selectedModel) {
|
||||
emit('change', selectedModel);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nNavigationDropdown
|
||||
:menu="menu"
|
||||
:disabled="disabled || models.length === 0"
|
||||
@select="onSelect"
|
||||
>
|
||||
<N8nButton :class="$style.dropdownButton" type="secondary">
|
||||
<span>{{ selectedLabel }}</span>
|
||||
<N8nIcon icon="chevron-down" size="small" />
|
||||
</N8nButton>
|
||||
</N8nNavigationDropdown>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.dropdownButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { Suggestion } from './chat.types';
|
||||
|
||||
// Route and view identifiers
|
||||
export const CHAT_VIEW = 'chat';
|
||||
|
||||
export const CHAT_STORE = 'chatStore';
|
||||
|
||||
export const SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
title: 'Brainstorm ideas',
|
||||
subtitle: 'for a product launch or campaign',
|
||||
icon: '💡',
|
||||
},
|
||||
{
|
||||
title: 'Explain a concept',
|
||||
subtitle: "like Docker as if I'm 12",
|
||||
icon: '📘',
|
||||
},
|
||||
{
|
||||
title: 'Summarize text',
|
||||
subtitle: 'paste content and get a TL;DR',
|
||||
icon: '📝',
|
||||
},
|
||||
{
|
||||
title: 'Draft an email',
|
||||
subtitle: 'polite follow-up about a bug',
|
||||
icon: '✉️',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
|
||||
import { CHAT_VIEW } from './constants';
|
||||
|
||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const ChatView = async () => await import('@/features/chatHub/ChatView.vue');
|
||||
|
||||
export const ChatModule: FrontendModuleDescription = {
|
||||
id: 'chat-hub',
|
||||
name: 'Chat',
|
||||
description: 'Interact with various LLM models or your n8n AI agents.',
|
||||
icon: 'chat',
|
||||
modals: [],
|
||||
routes: [
|
||||
{
|
||||
name: CHAT_VIEW,
|
||||
path: '/ask',
|
||||
components: {
|
||||
default: ChatView,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
],
|
||||
projectTabs: {
|
||||
overview: [],
|
||||
project: [],
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
key: 'chat',
|
||||
displayName: 'Chat',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -6,13 +6,19 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { InsightsModule } from '../features/insights/module.descriptor';
|
||||
import { MCPModule } from '../features/mcpAccess/module.descriptor';
|
||||
import { ChatModule } from '@/features/chatHub/module.descriptor';
|
||||
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
|
||||
import * as modalRegistry from '@/moduleInitializer/modalRegistry';
|
||||
|
||||
/**
|
||||
* Hard-coding modules list until we have a dynamic way to load modules.
|
||||
*/
|
||||
const modules: FrontendModuleDescription[] = [InsightsModule, DataTableModule, MCPModule];
|
||||
const modules: FrontendModuleDescription[] = [
|
||||
InsightsModule,
|
||||
DataTableModule,
|
||||
MCPModule,
|
||||
ChatModule,
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize modules resources (used in ResourcesListLayout), done in init.ts
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
|
||||
const isDataTableFeatureEnabled = computed(() => isModuleActive('data-table'));
|
||||
|
||||
const isChatFeatureEnabled = computed(() => isModuleActive('chat-hub'));
|
||||
|
||||
const areTagsEnabled = computed(() =>
|
||||
settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true,
|
||||
);
|
||||
|
|
@ -377,5 +379,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
activeModules,
|
||||
isModuleActive,
|
||||
isDataTableFeatureEnabled,
|
||||
isChatFeatureEnabled,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user