From 24a4de8cf94696f50fb4be3e52299537b2c2c8ac Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 21 Nov 2025 12:39:54 +0200 Subject: [PATCH] feat(core): Add Chat settings admin view (no-changelog) (#22009) --- packages/@n8n/api-types/src/chat-hub.ts | 24 +- .../@n8n/api-types/src/frontend-settings.ts | 11 +- packages/@n8n/api-types/src/index.ts | 2 + .../src/repositories/settings.repository.ts | 6 +- .../src/modules/chat-hub/chat-hub.module.ts | 6 +- .../src/modules/chat-hub/chat-hub.service.ts | 3 + .../chat-hub/chat-hub.settings.controller.ts | 42 +- .../chat-hub/chat-hub.settings.service.ts | 98 ++++- .../chat-hub/dto/chat-message-request.dto.ts | 7 - .../chat-hub/dto/update-chat-settings.dto.ts | 6 - .../N8nDataTableServer/N8nDataTableServer.vue | 2 +- .../frontend/@n8n/i18n/src/locales/en.json | 32 ++ .../ai/chatHub/SettingsChatHubView.vue | 122 ++++++ .../src/features/ai/chatHub/chat.api.ts | 36 ++ .../src/features/ai/chatHub/chat.store.ts | 53 ++- .../chatHub/components/ChatProvidersTable.vue | 251 +++++++++++ .../ai/chatHub/components/ModelSelector.vue | 53 ++- .../components/ProviderSettingsModal.vue | 407 ++++++++++++++++++ .../chatHub/composables/useChatCredentials.ts | 23 +- .../src/features/ai/chatHub/constants.ts | 2 + .../features/ai/chatHub/module.descriptor.ts | 70 ++- .../CredentialPicker/CredentialPicker.vue | 5 +- .../CredentialPicker/CredentialsDropdown.vue | 84 +++- .../shared/tags/components/TagsDropdown.vue | 6 +- 24 files changed, 1285 insertions(+), 66 deletions(-) delete mode 100644 packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts delete mode 100644 packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatProvidersTable.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/components/ProviderSettingsModal.vue diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index e48577e50c3..31b8e68d5ed 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -358,7 +358,7 @@ export class ChatHubCreateAgentRequest extends Z.class({ description: z.string().max(512).optional(), systemPrompt: z.string().min(1), credentialId: z.string(), - provider: chatHubProviderSchema.exclude(['n8n', 'custom-agent']), + provider: chatHubLLMProviderSchema, model: z.string().max(64), tools: z.array(INodeSchema), }) {} @@ -381,3 +381,25 @@ export interface EnrichedStructuredChunk extends StructuredChunk { executionId: number | null; }; } + +const chatProviderSettingsSchema = z.object({ + provider: chatHubLLMProviderSchema, + enabled: z.boolean().optional(), + credentialId: z.string().nullable(), + // Empty list = all models allowed + allowedModels: z.array( + z.object({ + displayName: z.string(), + model: z.string(), + isManual: z.boolean().optional(), + }), + ), + createdAt: z.string(), + updatedAt: z.string().nullable(), +}); + +export type ChatProviderSettingsDto = z.infer; + +export class UpdateChatSettingsRequest extends Z.class({ + payload: chatProviderSettingsSchema, +}) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index a42ee6d1067..1c5e8fab487 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -1,6 +1,7 @@ import type { LogLevel, WorkflowSettings } from 'n8n-workflow'; -import { type InsightsDateRange } from './schemas/insights.schema'; +import type { ChatHubLLMProvider, ChatProviderSettingsDto } from './chat-hub'; +import type { InsightsDateRange } from './schemas/insights.schema'; export interface IVersionNotificationSettings { enabled: boolean; @@ -245,6 +246,14 @@ export type FrontendModuleSettings = { /** Whether MCP access is enabled in the instance. */ mcpAccessEnabled: boolean; }; + + /** + * Client settings for Chat module. + */ + 'chat-hub'?: { + enabled: boolean; + providers: Record; + }; }; export type N8nEnvFeatFlagValue = boolean | string | number | undefined; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 1f2646f4518..faae5c3e485 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -46,6 +46,8 @@ export { ChatHubUpdateAgentRequest, type EnrichedStructuredChunk, type ChatHubAgentTool, + UpdateChatSettingsRequest, + type ChatProviderSettingsDto, } from './chat-hub'; export type { Collaborator } from './push/collaboration'; diff --git a/packages/@n8n/db/src/repositories/settings.repository.ts b/packages/@n8n/db/src/repositories/settings.repository.ts index 8632264afaa..7c83622ba12 100644 --- a/packages/@n8n/db/src/repositories/settings.repository.ts +++ b/packages/@n8n/db/src/repositories/settings.repository.ts @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; +import { DataSource, Like, Repository } from '@n8n/typeorm'; import { Settings } from '../entities'; @@ -12,4 +12,8 @@ export class SettingsRepository extends Repository { async findByKey(key: string): Promise { return await this.findOneBy({ key }); } + + async findByKeyPrefix(prefix: string): Promise { + return await this.findBy({ key: Like(`${prefix}%`) }); + } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.module.ts b/packages/cli/src/modules/chat-hub/chat-hub.module.ts index 047ea71aa8a..8c0f68c96d0 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.module.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.module.ts @@ -22,8 +22,10 @@ export class ChatHubModule implements ModuleInterface { async settings() { const { ChatHubSettingsService } = await import('./chat-hub.settings.service'); - const chatAccessEnabled = await Container.get(ChatHubSettingsService).getEnabled(); - return { chatAccessEnabled }; + const enabled = await Container.get(ChatHubSettingsService).getEnabled(); + const providers = await Container.get(ChatHubSettingsService).getAllProviderSettings(); + + return { enabled, providers }; } async entities() { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 0261ebe55c4..8be4025fed6 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -65,6 +65,7 @@ import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; +import { ChatHubSettingsService } from './chat-hub.settings.service'; import { ChatHubAttachmentService } from './chat-hub.attachment.service'; @Service() @@ -85,6 +86,7 @@ export class ChatHubService { private readonly chatHubAgentService: ChatHubAgentService, private readonly chatHubCredentialsService: ChatHubCredentialsService, private readonly chatHubWorkflowService: ChatHubWorkflowService, + private readonly chatHubSettingsService: ChatHubSettingsService, private readonly chatHubAttachmentService: ChatHubAttachmentService, ) {} @@ -1139,6 +1141,7 @@ export class ChatHubService { attachments: IBinaryData[], trx: EntityManager, ) { + await this.chatHubSettingsService.ensureModelIsAllowed(model); const credential = await this.chatHubCredentialsService.ensureCredentials( user, model.provider, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts index 70955454098..3c4a7720b2c 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts @@ -1,9 +1,14 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; import { type AuthenticatedRequest } from '@n8n/db'; -import { Body, Get, Patch, RestController, GlobalScope } from '@n8n/decorators'; +import { Body, Get, Post, RestController, GlobalScope, Param } from '@n8n/decorators'; import { ChatHubSettingsService } from './chat-hub.settings.service'; -import { UpdateChatSettingsDto } from './dto/update-chat-settings.dto'; +import { + ChatHubLLMProvider, + chatHubLLMProviderSchema, + UpdateChatSettingsRequest, +} from '@n8n/api-types'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @RestController('/chat') export class ChatHubSettingsController { @@ -14,20 +19,36 @@ export class ChatHubSettingsController { ) {} @Get('/settings') - async getSettings() { - const chatAccessEnabled = await this.settings.getEnabled(); - return { chatAccessEnabled }; + @GlobalScope('chatHub:manage') + async getSettings(_req: AuthenticatedRequest, _res: Response) { + const providers = await this.settings.getAllProviderSettings(); + return { providers }; } - @Patch('/settings') + @Get('/settings/:provider') + @GlobalScope('chatHub:manage') + async getProviderSettings( + _req: AuthenticatedRequest, + _res: Response, + @Param('provider') provider: ChatHubLLMProvider, + ) { + if (!chatHubLLMProviderSchema.safeParse(provider).success) { + throw new BadRequestError(`Invalid provider: ${provider}`); + } + + const settings = await this.settings.getProviderSettings(provider); + return { settings }; + } + + @Post('/settings') @GlobalScope('chatHub:manage') async updateSettings( _req: AuthenticatedRequest, _res: Response, - @Body dto: UpdateChatSettingsDto, + @Body body: UpdateChatSettingsRequest, ) { - const enabled = dto.chatAccessEnabled; - await this.settings.setEnabled(enabled); + const { payload } = body; + await this.settings.setProviderSettings(payload.provider, payload); try { await this.moduleRegistry.refreshModuleSettings('chat-hub'); } catch (error) { @@ -35,6 +56,7 @@ export class ChatHubSettingsController { cause: error instanceof Error ? error.message : String(error), }); } - return { chatAccessEnabled: enabled }; + + return await this.settings.getProviderSettings(payload.provider); } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts index b030d1b799f..f7a7b6b868c 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts @@ -1,14 +1,34 @@ +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { + ChatHubConversationModel, + ChatHubLLMProvider, + chatHubLLMProviderSchema, + ChatProviderSettingsDto, +} from '@n8n/api-types'; import { SettingsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +import { jsonParse } from 'n8n-workflow'; -const KEY = 'chat.access.enabled'; +const CHAT_PROVIDER_SETTINGS_KEY_PREFIX = 'chat.provider.'; +const CHAT_PROVIDER_SETTINGS_KEY = (provider: ChatHubLLMProvider) => + `${CHAT_PROVIDER_SETTINGS_KEY_PREFIX}${provider}`; +const CHAT_ENABLED_KEY = 'chat.access.enabled'; + +const getDefaultProviderSettings = (provider: ChatHubLLMProvider): ChatProviderSettingsDto => ({ + provider, + credentialId: null, + allowedModels: [], + createdAt: new Date().toISOString(), + updatedAt: null, + enabled: true, +}); @Service() export class ChatHubSettingsService { constructor(private readonly settingsRepository: SettingsRepository) {} async getEnabled(): Promise { - const row = await this.settingsRepository.findByKey(KEY); + const row = await this.settingsRepository.findByKey(CHAT_ENABLED_KEY); // Allowed by default if (!row) return true; return row.value === 'true'; @@ -16,6 +36,78 @@ export class ChatHubSettingsService { async setEnabled(enabled: boolean): Promise { const value = enabled ? 'true' : 'false'; - await this.settingsRepository.upsert({ key: KEY, value, loadOnStartup: true }, ['key']); + await this.settingsRepository.upsert({ key: CHAT_ENABLED_KEY, value, loadOnStartup: true }, [ + 'key', + ]); + } + + async ensureModelIsAllowed(model: ChatHubConversationModel): Promise { + if (model.provider === 'custom-agent' || model.provider === 'n8n') { + // Custom agents and n8n models are always allowed, for now + return; + } + + const settings = await this.getProviderSettings(model.provider); + if (!settings.enabled) { + throw new BadRequestError('Provider is not enabled'); + } + + if ( + settings.allowedModels.length > 0 && + !settings.allowedModels.some((m) => m.model === model.model) + ) { + throw new BadRequestError(`Model ${model.model} is not allowed`); + } + + return; + } + + async getProviderSettings(provider: ChatHubLLMProvider): Promise { + const settings = await this.settingsRepository.findByKey(CHAT_PROVIDER_SETTINGS_KEY(provider)); + if (!settings) { + return getDefaultProviderSettings(provider); + } + + return jsonParse(settings.value, { + fallbackValue: getDefaultProviderSettings(provider), + }); + } + + async getAllProviderSettings(): Promise> { + const settings = await this.settingsRepository.findByKeyPrefix( + CHAT_PROVIDER_SETTINGS_KEY_PREFIX, + ); + + const persistedByProvider = new Map(); + + for (const setting of settings) { + const parsed = jsonParse(setting.value); + persistedByProvider.set(parsed.provider, parsed); + } + + const result = {} as Record; + + // Ensure each provider has settings (use default if missing) + for (const provider of chatHubLLMProviderSchema.options) { + result[provider] = persistedByProvider.get(provider) ?? getDefaultProviderSettings(provider); + } + + return result; + } + + async setProviderSettings( + provider: ChatHubLLMProvider, + settings: ChatProviderSettingsDto, + ): Promise { + const value = JSON.stringify({ + ...settings, + createdAt: settings.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await this.settingsRepository.upsert( + { key: CHAT_PROVIDER_SETTINGS_KEY(provider), value, loadOnStartup: true }, + ['key'], + ); } } diff --git a/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts b/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts deleted file mode 100644 index c8c154f063c..00000000000 --- a/packages/cli/src/modules/chat-hub/dto/chat-message-request.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; -import { Z } from 'zod-class'; - -export class ChatMessageRequestDto extends Z.class({ - message: z.string(), - model: z.string(), -}) {} diff --git a/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts b/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts deleted file mode 100644 index 2d241f2e939..00000000000 --- a/packages/cli/src/modules/chat-hub/dto/update-chat-settings.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; -import { Z } from 'zod-class'; - -export class UpdateChatSettingsDto extends Z.class({ - chatAccessEnabled: z.boolean(), -}) {} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue index fccc1ba69a7..0f37575e770 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue @@ -290,7 +290,7 @@ const selectColumn: ColumnDef = { }, meta: { cellProps: { - align: undefined, + align: 'start', }, }, }; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e5cfeb709a7..cb6b1d722e1 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2279,6 +2279,38 @@ "settings.mcp.oAuthClients.table.empty.title": "No OAuth clients connected", "settings.mcp.refresh.tooltip": "Refresh list", "settings.mcp.workflowsTable.workflow": "Workflow", + "settings.chatHub": "Chat", + "settings.chatHub.providers.fetching.error": "Error fetching chat provider settings", + "settings.chatHub.providers.updated.success": "Chat provider settings updated", + "settings.chatHub.providers.updated.error": "Error updating chat provider settings", + "settings.chatHub.providers.table.provider": "Provider", + "settings.chatHub.providers.table.models": "Models", + "settings.chatHub.providers.table.createdAt": "Created", + "settings.chatHub.providers.table.updatedAt": "Last edited", + "settings.chatHub.providers.table.action.editProvider": "Edit provider", + "settings.chatHub.providers.table.action.editProvider.disabled": "Only instance admins and owners can edit chat providers.", + "settings.chatHub.providers.table.title": "Providers", + "settings.chatHub.providers.table.refresh.tooltip": "Refresh list", + "settings.chatHub.providers.table.addProvider.button": "Add provider", + "settings.chatHub.providers.table.empty.title": "No chat providers configured", + "settings.chatHub.providers.table.empty.description": "Configure chat providers to restrict available models and credentials.", + "settings.chatHub.providers.modal.edit.title": "Configure {provider}", + "settings.chatHub.providers.modal.edit.cancel": "Cancel", + "settings.chatHub.providers.modal.edit.confirm": "Confirm", + "settings.chatHub.providers.modal.edit.enabled.label": "Enable {provider}", + "settings.chatHub.providers.modal.edit.enabled.tooltip": "When disabled, models from this provider won't be available for use in Chat.", + "settings.chatHub.providers.modal.edit.credential.label": "Default credential", + "settings.chatHub.providers.modal.edit.credential.clearButton": "Clear selection", + "settings.chatHub.providers.modal.edit.limitModels.label": "Limit models", + "settings.chatHub.providers.modal.edit.limitModels.tooltip": "When enabled only selected models will be available for use in Chat.", + "settings.chatHub.providers.modal.edit.allowedModels.label": "Models", + "settings.chatHub.providers.modal.edit.errorFetchingModels": "Error fetching chat models", + "settings.chatHub.providers.modal.edit.models.placeholder": "Select a model", + "settings.chatHub.providers.modal.edit.models.create": "Add model \"{filter}\"", + "settings.chatHub.providers.table.models.disabled": "Disabled", + "settings.chatHub.providers.table.models.allModels": "All models", + "settings.chatHub.providers.table.models.noModels": "No models", + "settings.chatHub.providers.table.models.more": " and {count} more", "settings.goBack": "Go back", "settings.personal": "Personal", "settings.personal.basicInformation": "Basic Information", diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue new file mode 100644 index 00000000000..b3869656eee --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue @@ -0,0 +1,122 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts index 55a96fa9476..b61a737237d 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts @@ -15,7 +15,9 @@ import type { ChatHubUpdateAgentRequest, ChatHubUpdateConversationRequest, EnrichedStructuredChunk, + ChatHubLLMProvider, } from '@n8n/api-types'; +import type { ChatProviderSettingsDto } from '@n8n/api-types'; // Workflows stream data as newline separated JSON objects (jsonl) const STREAM_SEPARATOR = '\n'; @@ -183,6 +185,40 @@ export const deleteAgentApi = async (context: IRestApiContext, agentId: string): await makeRestApiRequest(context, 'DELETE', apiEndpoint); }; +export const fetchChatSettingsApi = async ( + context: IRestApiContext, +): Promise> => { + const apiEndpoint = '/chat/settings'; + const response = await makeRestApiRequest<{ + providers: Record; + }>(context, 'GET', apiEndpoint); + return response.providers; +}; + +export const fetchChatProviderSettingsApi = async ( + context: IRestApiContext, + provider: ChatHubLLMProvider, +): Promise => { + const apiEndpoint = '/chat/settings/' + provider; + const response = await makeRestApiRequest<{ settings: ChatProviderSettingsDto }>( + context, + 'GET', + apiEndpoint, + ); + return response.settings; +}; + +export const updateChatSettingsApi = async ( + context: IRestApiContext, + settings: ChatProviderSettingsDto, +): Promise => { + const apiEndpoint = '/chat/settings'; + + return await makeRestApiRequest(context, 'POST', apiEndpoint, { + payload: settings, + }); +}; + export function buildChatAttachmentUrl( context: IRestApiContext, sessionId: string, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index 9d38c3905a9..200cc016d8b 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -18,6 +18,9 @@ import { updateAgentApi, deleteAgentApi, updateConversationApi, + fetchChatSettingsApi, + fetchChatProviderSettingsApi, + updateChatSettingsApi, } from './chat.api'; import { useRootStore } from '@n8n/stores/useRootStore'; import { @@ -49,7 +52,8 @@ import { buildUiMessages, isMatchedAgent } from './chat.utils'; import { createAiMessageFromStreamingState, flattenModel } from './chat.utils'; import { useToast } from '@/app/composables/useToast'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { type INode } from 'n8n-workflow'; +import { deepCopy, type INode } from 'n8n-workflow'; +import type { ChatHubLLMProvider, ChatProviderSettingsDto } from '@n8n/api-types'; export const useChatStore = defineStore(CHAT_STORE, () => { const rootStore = useRootStore(); @@ -62,7 +66,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => { const currentEditingAgent = ref(null); const streaming = ref(); - + const settingsLoading = ref(false); + const settings = ref | null>(null); const conversationsBySession = ref>(new Map()); const getConversation = (sessionId: ChatSessionId): ChatConversation | undefined => @@ -804,6 +809,41 @@ export const useChatStore = defineStore(CHAT_STORE, () => { return agent; } + async function fetchAllChatSettings() { + try { + settingsLoading.value = true; + settings.value = await fetchChatSettingsApi(rootStore.restApiContext); + } finally { + settingsLoading.value = false; + } + + return settings.value; + } + + async function fetchProviderSettings(provider: ChatHubLLMProvider) { + const providerSettings = await fetchChatProviderSettingsApi(rootStore.restApiContext, provider); + + if (settings.value) { + settings.value[provider] = deepCopy(providerSettings); + } + + return providerSettings; + } + + async function updateProviderSettings(updated: ChatProviderSettingsDto) { + if (!updated.enabled) { + updated.allowedModels = []; + } + + const saved = await updateChatSettingsApi(rootStore.restApiContext, updated); + + if (settings.value) { + settings.value[updated.provider] = deepCopy(saved); + } + + return saved; + } + return { /** * models and agents @@ -850,5 +890,14 @@ export const useChatStore = defineStore(CHAT_STORE, () => { editMessage, regenerateMessage, stopStreamingMessage, + + /** + * settings + */ + settings, + settingsLoading, + fetchAllChatSettings, + fetchProviderSettings, + updateProviderSettings, }; }); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatProvidersTable.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatProvidersTable.vue new file mode 100644 index 00000000000..2cea83bc905 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatProvidersTable.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue index d5fbb435402..4a72d3e8c97 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ModelSelector.vue @@ -35,6 +35,7 @@ import { import { fetchChatModelsApi } from '@/features/ai/chatHub/chat.api'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useTelemetry } from '@/app/composables/useTelemetry'; +import { useSettingsStore } from '@/app/stores/settings.store'; const NEW_AGENT_MENU_ID = 'agent::new'; @@ -76,6 +77,7 @@ const i18n = useI18n(); const agents = ref(emptyChatModelsResponse); const dropdownRef = useTemplateRef('dropdownRef'); const uiStore = useUIStore(); +const settingStore = useSettingsStore(); const credentialsStore = useCredentialsStore(); const telemetry = useTelemetry(); @@ -124,12 +126,44 @@ const menu = computed(() => { } for (const provider of chatHubLLMProviderSchema.options) { - const theAgents = agents.value[provider].models; + const settings = settingStore.moduleSettings?.['chat-hub']?.providers[provider]; + + // Filter out disabled providers from the menu + if (settings && !settings.enabled) continue; + + const theAgents = [...agents.value[provider].models]; + + // Add any manually defined models in settings + for (const model of settings?.allowedModels ?? []) { + if (model.isManual) { + theAgents.push({ + name: model.displayName, + description: '', + model: { + provider, + model: model.model, + }, + createdAt: '', + updatedAt: null, + }); + } + } + const error = agents.value[provider].error; const agentOptions = theAgents.length > 0 ? theAgents .filter((agent) => agent.model.provider !== 'custom-agent') + .filter( + (agent) => + agent.model.provider === 'n8n' || + // Filter out models not allowed in settings + !settings || + settings.allowedModels.length === 0 || + settings.allowedModels.some( + (m) => 'model' in agent.model && m.model === agent.model.model, + ), + ) .map['menu'][number]>((agent) => ({ id: stringifyModel(agent.model), title: agent.name, @@ -141,12 +175,17 @@ const menu = computed(() => { const submenu = agentOptions.concat([ ...(agentOptions.length > 0 ? [{ isDivider: true as const, id: 'divider' }] : []), - { - id: `${provider}::add-model`, - icon: 'plus', - title: i18n.baseText('chatHub.agent.addModel'), - disabled: false, - }, + ...(settings?.allowedModels.length === 0 + ? [ + // Disallow "Add model" if models are limited in settings + { + id: `${provider}::add-model`, + icon: 'plus', + title: i18n.baseText('chatHub.agent.addModel'), + disabled: false, + } as const, + ] + : []), { id: `${provider}::configure`, icon: 'settings', diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ProviderSettingsModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ProviderSettingsModal.vue new file mode 100644 index 00000000000..4a3611db967 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ProviderSettingsModal.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts index 3394592fa8e..5ad194234cc 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts @@ -1,4 +1,5 @@ import { LOCAL_STORAGE_CHAT_HUB_CREDENTIALS } from '@/app/constants'; +import { useSettingsStore } from '@/app/stores/settings.store'; import { credentialsMapSchema, type CredentialsMap } from '@/features/ai/chatHub/chat.types'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { @@ -15,6 +16,8 @@ import { computed, onMounted, ref } from 'vue'; export function useChatCredentials(userId: string) { const isInitialized = ref(false); const credentialsStore = useCredentialsStore(); + const settingsStore = useSettingsStore(); + const selectedCredentials = useLocalStorage( LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(userId), {}, @@ -42,15 +45,27 @@ export function useChatCredentials(userId: string) { } const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider]; - if (!credentialType) { return [provider, null]; } + const availableCredentials = credentialsStore.getCredentialsByType(credentialType); + + const settings = settingsStore.moduleSettings?.['chat-hub']?.providers[provider]; + + // Use default credential from settings if available to the user + if ( + settings && + settings.credentialId && + availableCredentials.some((c) => c.id === settings.credentialId) + ) { + return [provider, settings.credentialId]; + } + const lastCreatedCredential = - credentialsStore - .getCredentialsByType(credentialType) - .toSorted((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0]?.id ?? null; + availableCredentials.toSorted( + (a, b) => +new Date(b.createdAt) - +new Date(a.createdAt), + )[0]?.id ?? null; return [provider, lastCreatedCredential]; }), diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/constants.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/constants.ts index 0b13aae578d..baa04774224 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/constants.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/constants.ts @@ -4,6 +4,7 @@ import type { ChatHubProvider } from '@n8n/api-types'; export const CHAT_VIEW = 'chat'; export const CHAT_CONVERSATION_VIEW = 'chat-conversation'; export const CHAT_AGENTS_VIEW = 'chat-agents'; +export const CHAT_SETTINGS_VIEW = 'chat-settings'; export const CHAT_STORE = 'chatStore'; @@ -29,3 +30,4 @@ export const TOOLS_SELECTOR_MODAL_KEY = 'toolsSelectorModal'; export const AGENT_EDITOR_MODAL_KEY = 'agentEditorModal'; export const CHAT_CREDENTIAL_SELECTOR_MODAL_KEY = 'chatCredentialSelectorModal'; export const CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY = 'chatModelByIdSelectorModal'; +export const CHAT_PROVIDER_SETTINGS_MODAL_KEY = 'chatProviderSettingsModal'; diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts index 7dc03e0ca9d..2bee76773ad 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts @@ -7,7 +7,11 @@ import { AGENT_EDITOR_MODAL_KEY, CHAT_CREDENTIAL_SELECTOR_MODAL_KEY, CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY, + CHAT_SETTINGS_VIEW, + CHAT_PROVIDER_SETTINGS_MODAL_KEY, } from '@/features/ai/chatHub/constants'; +import { i18n } from '@n8n/i18n'; +import SettingsChatHubView from './SettingsChatHubView.vue'; const ChatSidebar = async () => await import('@/features/ai/chatHub/components/ChatSidebar.vue'); const ChatView = async () => await import('@/features/ai/chatHub/ChatView.vue'); @@ -67,6 +71,19 @@ export const ChatModule: FrontendModuleDescription = { }, }, }, + { + key: CHAT_PROVIDER_SETTINGS_MODAL_KEY, + component: async () => await import('./components/ProviderSettingsModal.vue'), + initialState: { + open: false, + data: { + provider: null, + disabled: false, + onConfirm: () => {}, + onCancel: () => {}, + }, + }, + }, ], routes: [ { @@ -77,7 +94,12 @@ export const ChatModule: FrontendModuleDescription = { sidebar: ChatSidebar, }, meta: { - middleware: ['authenticated', 'custom'], + middleware: ['authenticated'], + getProperties() { + return { + feature: 'chat-hub', + }; + }, }, }, { @@ -88,7 +110,12 @@ export const ChatModule: FrontendModuleDescription = { sidebar: ChatSidebar, }, meta: { - middleware: ['authenticated', 'custom'], + middleware: ['authenticated'], + getProperties() { + return { + feature: 'chat-hub', + }; + }, }, }, { @@ -99,7 +126,35 @@ export const ChatModule: FrontendModuleDescription = { sidebar: ChatSidebar, }, meta: { - middleware: ['authenticated', 'custom'], + middleware: ['authenticated'], + getProperties() { + return { + feature: 'chat-hub', + }; + }, + }, + }, + { + path: 'chat', + name: CHAT_SETTINGS_VIEW, + components: { + settingsView: SettingsChatHubView, + }, + meta: { + middleware: ['authenticated', 'rbac'], + middlewareOptions: { + rbac: { + scope: ['chatHub:manage'], + }, + }, + telemetry: { + pageCategory: 'settings', + getProperties() { + return { + feature: 'chat-hub', + }; + }, + }, }, }, ], @@ -113,4 +168,13 @@ export const ChatModule: FrontendModuleDescription = { displayName: 'Chat', }, ], + settingsPages: [ + { + id: 'settings-chat-hub', + icon: 'message-circle', + label: i18n.baseText('settings.chatHub'), + position: 'top', + route: { to: { name: CHAT_SETTINGS_VIEW } }, + }, + ], }; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialPicker/CredentialPicker.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialPicker/CredentialPicker.vue index 1767b0a3d0d..472bd43cef7 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialPicker/CredentialPicker.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialPicker/CredentialPicker.vue @@ -12,6 +12,7 @@ const props = defineProps<{ appName: string; credentialType: string; selectedCredentialId: string | null; + hideCreateNew?: boolean; }>(); const emit = defineEmits<{ @@ -97,7 +98,7 @@ listenForModalChanges({