feat(core): Add Chat settings admin view (no-changelog) (#22009)

This commit is contained in:
Jaakko Husso 2025-11-21 12:39:54 +02:00 committed by GitHub
parent 731024c941
commit 24a4de8cf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1285 additions and 66 deletions

View File

@ -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<typeof chatProviderSettingsSchema>;
export class UpdateChatSettingsRequest extends Z.class({
payload: chatProviderSettingsSchema,
}) {}

View File

@ -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<ChatHubLLMProvider, ChatProviderSettingsDto>;
};
};
export type N8nEnvFeatFlagValue = boolean | string | number | undefined;

View File

@ -46,6 +46,8 @@ export {
ChatHubUpdateAgentRequest,
type EnrichedStructuredChunk,
type ChatHubAgentTool,
UpdateChatSettingsRequest,
type ChatProviderSettingsDto,
} from './chat-hub';
export type { Collaborator } from './push/collaboration';

View File

@ -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<Settings> {
async findByKey(key: string): Promise<Settings | null> {
return await this.findOneBy({ key });
}
async findByKeyPrefix(prefix: string): Promise<Settings[]> {
return await this.findBy({ key: Like(`${prefix}%`) });
}
}

View File

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

View File

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

View File

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

View File

@ -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<boolean> {
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<void> {
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<void> {
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<ChatProviderSettingsDto> {
const settings = await this.settingsRepository.findByKey(CHAT_PROVIDER_SETTINGS_KEY(provider));
if (!settings) {
return getDefaultProviderSettings(provider);
}
return jsonParse<ChatProviderSettingsDto>(settings.value, {
fallbackValue: getDefaultProviderSettings(provider),
});
}
async getAllProviderSettings(): Promise<Record<ChatHubLLMProvider, ChatProviderSettingsDto>> {
const settings = await this.settingsRepository.findByKeyPrefix(
CHAT_PROVIDER_SETTINGS_KEY_PREFIX,
);
const persistedByProvider = new Map<ChatHubLLMProvider, ChatProviderSettingsDto>();
for (const setting of settings) {
const parsed = jsonParse<ChatProviderSettingsDto>(setting.value);
persistedByProvider.set(parsed.provider, parsed);
}
const result = {} as Record<ChatHubLLMProvider, ChatProviderSettingsDto>;
// 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<void> {
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'],
);
}
}

View File

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

View File

@ -1,6 +0,0 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class UpdateChatSettingsDto extends Z.class({
chatAccessEnabled: z.boolean(),
}) {}

View File

@ -290,7 +290,7 @@ const selectColumn: ColumnDef<T> = {
},
meta: {
cellProps: {
align: undefined,
align: 'start',
},
},
};

View File

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

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useToast } from '@/app/composables/useToast';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted } from 'vue';
import { useChatStore } from './chat.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { N8nHeading } from '@n8n/design-system';
import { CHAT_PROVIDER_SETTINGS_MODAL_KEY } from './constants';
import ChatProvidersTable from './components/ChatProvidersTable.vue';
import {
type ChatHubLLMProvider,
type ChatProviderSettingsDto,
PROVIDER_CREDENTIAL_TYPE_MAP,
} from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
const i18n = useI18n();
const toast = useToast();
const documentTitle = useDocumentTitle();
const chatStore = useChatStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const isOwner = computed(() => usersStore.isInstanceOwner);
const isAdmin = computed(() => usersStore.isAdmin);
const disabled = computed(() => !isOwner.value && !isAdmin.value);
const fetchSettings = async () => {
try {
await chatStore.fetchAllChatSettings();
} catch (error) {
toast.showError(error, i18n.baseText('settings.chatHub.providers.fetching.error'));
}
};
function onEditProvider(settings: ChatProviderSettingsDto) {
uiStore.openModalWithData({
name: CHAT_PROVIDER_SETTINGS_MODAL_KEY,
data: {
provider: settings.provider,
disabled: disabled.value,
onNewCredential: (provider: ChatHubLLMProvider) => {
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
telemetry.track('User opened Credential modal', {
credential_type: credentialType,
source: 'chat_hub_settings',
new_credential: true,
workflow_id: null,
});
uiStore.openNewCredential(credentialType);
},
onConfirm: async (updatedSettings: ChatProviderSettingsDto) => {
try {
await chatStore.updateProviderSettings(updatedSettings);
toast.showMessage({
title: i18n.baseText('settings.chatHub.providers.updated.success'),
type: 'success',
});
await settingsStore.getModuleSettings();
} catch (error) {
toast.showError(error, i18n.baseText('settings.chatHub.providers.updated.error'));
}
},
onCancel: () => {},
},
});
}
async function onRefreshWorkflows() {
await fetchSettings();
}
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.chatHub'));
if (!settingsStore.isChatFeatureEnabled) {
return;
}
await fetchSettings();
await credentialsStore.fetchAllCredentials();
await credentialsStore.fetchCredentialTypes(false);
});
</script>
<template>
<div :class="$style.container">
<N8nHeading size="2xlarge" :class="$style.title">
{{ i18n.baseText('settings.chatHub') }}
</N8nHeading>
<div :class="$style.container">
<ChatProvidersTable
:data-test-id="'chat-providers-table'"
:settings="chatStore.settings"
:loading="chatStore.settingsLoading"
:disabled="disabled"
@edit-provider="onEditProvider"
@refresh="onRefreshWorkflows"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.title {
margin-bottom: var(--spacing--sm);
}
</style>

View File

@ -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<Record<ChatHubLLMProvider, ChatProviderSettingsDto>> => {
const apiEndpoint = '/chat/settings';
const response = await makeRestApiRequest<{
providers: Record<ChatHubLLMProvider, ChatProviderSettingsDto>;
}>(context, 'GET', apiEndpoint);
return response.providers;
};
export const fetchChatProviderSettingsApi = async (
context: IRestApiContext,
provider: ChatHubLLMProvider,
): Promise<ChatProviderSettingsDto> => {
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<ChatProviderSettingsDto> => {
const apiEndpoint = '/chat/settings';
return await makeRestApiRequest<ChatProviderSettingsDto>(context, 'POST', apiEndpoint, {
payload: settings,
});
};
export function buildChatAttachmentUrl(
context: IRestApiContext,
sessionId: string,

View File

@ -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<ChatHubAgentDto | null>(null);
const streaming = ref<ChatStreamingState>();
const settingsLoading = ref(false);
const settings = ref<Record<ChatHubLLMProvider, ChatProviderSettingsDto> | null>(null);
const conversationsBySession = ref<Map<ChatSessionId, ChatConversation>>(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,
};
});

View File

@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { type TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
import {
N8nActionBox,
N8nActionToggle,
N8nButton,
N8nDataTableServer,
N8nHeading,
N8nLoading,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import {
type ChatHubLLMProvider,
type ChatProviderSettingsDto,
PROVIDER_CREDENTIAL_TYPE_MAP,
} from '@n8n/api-types';
import { providerDisplayNames } from '../constants';
import TimeAgo from '@/app/components/TimeAgo.vue';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
const TRUNCATE_MODELS_AFTER = 4;
type Props = {
settings: Record<ChatHubLLMProvider, ChatProviderSettingsDto> | null;
loading: boolean;
disabled: boolean;
};
const props = defineProps<Props>();
const emit = defineEmits<{
editProvider: [provider: ChatProviderSettingsDto];
refresh: [];
}>();
const i18n = useI18n();
const tableHeaders = ref<Array<TableHeader<ChatProviderSettingsDto>>>([
{
title: i18n.baseText('settings.chatHub.providers.table.provider'),
key: 'provider',
width: 80,
disableSort: true,
value() {
return;
},
},
{
title: i18n.baseText('settings.chatHub.providers.table.models'),
key: 'models',
width: 300,
disableSort: true,
value() {
return;
},
},
{
title: i18n.baseText('settings.chatHub.providers.table.updatedAt'),
key: 'updatedAt',
disableSort: true,
width: 80,
value() {
return;
},
},
{
title: '',
key: 'actions',
align: 'end',
width: 50,
disableSort: true,
value() {
return;
},
},
]);
const tableActions = computed(() => [
{
label: i18n.baseText('settings.chatHub.providers.table.action.editProvider'),
value: 'editProvider',
disabled: props.disabled,
},
]);
const settingItems = computed(() => {
return props.settings ? Object.values(props.settings) : [];
});
const modelsText = (settings: ChatProviderSettingsDto) => {
if (!settings.enabled) {
return i18n.baseText('settings.chatHub.providers.table.models.disabled');
} else if (settings.allowedModels.length === 0) {
return i18n.baseText('settings.chatHub.providers.table.models.allModels');
} else {
if (settings.allowedModels.length > TRUNCATE_MODELS_AFTER) {
return (
settings.allowedModels
.slice(0, TRUNCATE_MODELS_AFTER)
.map((m) => m.displayName)
.join(', ') +
i18n.baseText('settings.chatHub.providers.table.models.more', {
interpolate: {
count: settings.allowedModels.length - TRUNCATE_MODELS_AFTER,
},
})
);
}
return settings.allowedModels.map((m) => m.displayName).join(', ');
}
};
const onTableAction = (action: string, settings: ChatProviderSettingsDto) => {
switch (action) {
case 'editProvider':
emit('editProvider', settings);
break;
default:
break;
}
};
</script>
<template>
<div :class="$style.tableContainer">
<div v-if="props.loading">
<N8nLoading :loading="props.loading" variant="h1" :class="$style.header" />
<N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
</div>
<div v-else :class="$style.container">
<div :class="$style.header">
<N8nHeading size="medium" :bold="true">
{{ i18n.baseText('settings.chatHub.providers.table.title') }}
</N8nHeading>
<div :class="$style.actions">
<N8nTooltip :content="i18n.baseText('settings.chatHub.providers.table.refresh.tooltip')">
<N8nButton
size="small"
type="tertiary"
icon="refresh-cw"
:square="true"
@click="$emit('refresh')"
/>
</N8nTooltip>
</div>
</div>
<N8nActionBox
v-if="!props.settings"
:heading="i18n.baseText('settings.chatHub.providers.table.empty.title')"
:description="i18n.baseText('settings.chatHub.providers.table.empty.description')"
/>
<N8nDataTableServer
v-else
:class="$style.chatProvidersTable"
:headers="tableHeaders"
:items="settingItems"
:items-length="settingItems.length"
>
<template #[`item.provider`]="{ item }">
<div :class="$style.providerCell">
<CredentialIcon
v-if="item.provider in PROVIDER_CREDENTIAL_TYPE_MAP"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[item.provider]"
:size="16"
:class="$style.menuIcon"
/>
<N8nText bold>
{{ providerDisplayNames[item.provider] }}
</N8nText>
</div>
</template>
<template #[`item.models`]="{ item }">
<N8nTooltip
v-if="item.allowedModels?.length && item.allowedModels?.length > TRUNCATE_MODELS_AFTER"
:content="
item.allowedModels
?.map((m: ChatProviderSettingsDto['allowedModels'][number]) => m.displayName)
.join(', ')
"
>
<N8nText :color="item.enabled ? 'text-base' : 'primary'">
{{ modelsText(item) }}
</N8nText>
</N8nTooltip>
<N8nText v-else :color="item.enabled ? 'text-base' : 'primary'">
{{ modelsText(item) }}
</N8nText>
</template>
<template #[`item.updatedAt`]="{ item }">
<span>
<TimeAgo v-if="item.updatedAt" :date="item.updatedAt" />
<N8nText v-else>-</N8nText>
</span>
</template>
<template #[`item.actions`]="{ item }">
<N8nActionToggle
placement="bottom"
:actions="tableActions"
theme="dark"
@action="onTableAction($event, item)"
/>
</template>
</N8nDataTableServer>
</div>
</div>
</template>
<style lang="scss" module>
.container {
margin-top: var(--spacing--sm);
margin-bottom: var(--spacing--xl);
}
.tableContainer {
:global(.table-pagination) {
display: none;
}
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing--sm);
}
.chatProvidersTable {
tr:last-child {
border-bottom: none !important;
}
}
.menuIcon {
flex-shrink: 0;
}
.providerCell {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
</style>

View File

@ -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<ChatModelsResponse>(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<ComponentProps<typeof N8nNavigationDropdown>['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',

View File

@ -0,0 +1,407 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { createEventBus } from '@n8n/utils/event-bus';
import {
type ChatHubLLMProvider,
type ChatModelDto,
type ChatProviderSettingsDto,
PROVIDER_CREDENTIAL_TYPE_MAP,
} from '@n8n/api-types';
import { ElSwitch } from 'element-plus';
import { useI18n } from '@n8n/i18n';
import { useChatStore } from '../chat.store';
import { providerDisplayNames } from '../constants';
import { fetchChatModelsApi } from '../chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
import CredentialPicker from '@/features/credentials/components/CredentialPicker/CredentialPicker.vue';
import TagsDropdown from '@/features/shared/tags/components/TagsDropdown.vue';
import { type ITag } from '@n8n/rest-api-client';
interface IModel extends ITag {
isManual?: boolean;
}
const props = defineProps<{
modalName: string;
data: {
provider: ChatHubLLMProvider;
disabled: boolean;
onConfirm: (settings: ChatProviderSettingsDto) => void;
onCancel: () => void;
};
}>();
const settings = ref<ChatProviderSettingsDto | null>(null);
const modalBus = ref(createEventBus());
const loadingSettings = ref(false);
const loadingModels = ref(false);
const limitModels = ref(false);
const availableModels = ref<ChatModelDto[]>([]);
const customModels = ref<string[]>([]);
const allModels = computed<IModel[]>(() => {
const models: Map<string, IModel> = new Map(
availableModels.value.reduce<Array<[string, IModel]>>((acc, model) => {
if (model.model.provider !== 'custom-agent' && model.model.provider !== 'n8n') {
acc.push([
model.model.model,
{
id: model.model.model,
name: model.name,
},
]);
}
return acc;
}, []),
);
for (const model of customModels.value) {
models.set(model, {
id: model,
name: model,
isManual: true,
});
}
return Array.from(models.values());
});
const modelsById = computed<Record<string, IModel>>(() => {
const map: Record<string, IModel> = {};
allModels.value.forEach((model) => {
map[model.id] = model;
});
return map;
});
const selectedModels = computed({
get: () => settings.value?.allowedModels?.map((m) => m.model) || [],
set: (value) => {
if (settings.value) {
settings.value.allowedModels = allModels.value
.filter((model) => value.includes(model.id))
.map((model) => ({
model: model.id,
displayName: model.name,
isManual: model.isManual,
}));
customModels.value = settings.value.allowedModels
.filter((model) => model.isManual)
.map((model) => model.model);
}
},
});
async function addManualModel(name: string): Promise<IModel> {
customModels.value.push(name);
return {
id: name,
name,
};
}
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const chatStore = useChatStore();
const toast = useToast();
const credentialType = computed(() => {
return PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider];
});
function onCredentialSelect(credentialId: string) {
if (settings.value) {
settings.value.credentialId = credentialId;
}
}
function onCredentialDeselect() {
if (settings.value) {
settings.value.credentialId = null;
settings.value.allowedModels = [];
limitModels.value = false;
}
}
function onConfirm() {
if (settings.value) {
props.data.onConfirm(settings.value);
} else {
props.data.onCancel();
}
modalBus.value.emit('close');
}
function onCancel() {
props.data.onCancel();
modalBus.value.emit('close');
}
async function loadSettings() {
settings.value = await chatStore.fetchProviderSettings(props.data.provider);
limitModels.value = settings.value?.allowedModels.length > 0;
customModels.value = settings.value.allowedModels
.filter((model) => model.isManual)
.map((model) => model.model);
}
async function loadAvailableModels(credentialId: string) {
loadingModels.value = true;
try {
const credentials = {
[props.data.provider]: credentialId,
};
const response = await fetchChatModelsApi(useRootStore().restApiContext, { credentials });
availableModels.value = response[props.data.provider].models || [];
} catch (error) {
toast.showError(
error,
i18n.baseText('settings.chatHub.providers.modal.edit.errorFetchingModels'),
);
} finally {
loadingModels.value = false;
}
}
const isConfirmDisabled = computed(() => {
if (props.data.disabled) return true;
if (!settings.value) return true;
return limitModels.value && settings.value.allowedModels.length === 0;
});
function onToggleEnabled(value: string | number | boolean) {
if (settings.value) {
settings.value.enabled = typeof value === 'boolean' ? value : Boolean(value);
if (!settings.value.enabled) {
settings.value.credentialId = null;
settings.value.allowedModels = [];
limitModels.value = false;
}
}
}
function onToggleLimitModels(value: string | number | boolean) {
if (settings.value) {
limitModels.value = typeof value === 'boolean' ? value : Boolean(value);
if (!limitModels.value) {
settings.value.allowedModels = [];
}
}
}
onMounted(async () => {
loadingSettings.value = true;
await Promise.all([
loadSettings(),
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
loadingSettings.value = false;
});
watch(
() => settings.value?.credentialId,
async (credentialId) => {
if (credentialId) {
await loadAvailableModels(credentialId);
}
},
{ immediate: true },
);
</script>
<template>
<Modal :name="modalName" :event-bus="modalBus" width="50%" max-width="500px" :center="true">
<template #header>
<div :class="$style.header">
<N8nHeading size="large" color="text-dark">{{
i18n.baseText('settings.chatHub.providers.modal.edit.title', {
interpolate: { provider: providerDisplayNames[props.data.provider] },
})
}}</N8nHeading>
</div>
</template>
<template #content>
<div :class="$style.content">
<div :class="$style.container">
<N8nText tag="label" color="text-dark">
{{
i18n.baseText('settings.chatHub.providers.modal.edit.enabled.label', {
interpolate: { provider: providerDisplayNames[props.data.provider] },
})
}}
</N8nText>
<N8nTooltip
:content="i18n.baseText('settings.chatHub.providers.modal.edit.enabled.tooltip')"
:disabled="props.data.disabled"
placement="top"
>
<ElSwitch
size="large"
:model-value="settings?.enabled ?? false"
:disabled="props.data.disabled"
:loading="loadingSettings"
@update:model-value="onToggleEnabled"
/>
</N8nTooltip>
</div>
<div v-if="settings && settings.enabled" :class="$style.container">
<N8nText tag="label" color="text-dark">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.credential.label') }}
</N8nText>
<div :class="$style.credentialContainer">
<CredentialPicker
:class="$style.credentialPicker"
:app-name="providerDisplayNames[props.data.provider]"
:credential-type="credentialType"
:selected-credential-id="settings.credentialId"
:hide-create-new="true"
@credential-selected="onCredentialSelect"
@credential-deselected="onCredentialDeselect"
/>
<N8nIconButton
v-if="settings.credentialId"
native-type="button"
:title="i18n.baseText('settings.chatHub.providers.modal.edit.credential.clearButton')"
icon="x"
icon-size="large"
type="secondary"
@click="onCredentialDeselect"
/>
</div>
</div>
<div v-if="settings && settings.enabled && settings.credentialId" :class="$style.container">
<N8nText tag="label" color="text-dark">
{{
i18n.baseText('settings.chatHub.providers.modal.edit.limitModels.label', {
interpolate: { provider: providerDisplayNames[props.data.provider] },
})
}}
</N8nText>
<div :class="$style.toggle">
<N8nTooltip
:content="i18n.baseText('settings.chatHub.providers.modal.edit.limitModels.tooltip')"
:disabled="props.data.disabled"
placement="top"
>
<ElSwitch
size="large"
:model-value="limitModels"
:disabled="props.data.disabled"
:loading="loadingSettings"
@update:model-value="onToggleLimitModels"
/>
</N8nTooltip>
</div>
</div>
<div
v-if="settings && settings.enabled && settings.credentialId && limitModels"
:class="$style.container"
>
<N8nText tag="label" color="text-dark">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.allowedModels.label') }}
</N8nText>
<TagsDropdown
v-model="selectedModels"
:class="$style.modelPicker"
:placeholder="i18n.baseText('settings.chatHub.providers.modal.edit.models.placeholder')"
:event-bus="null"
:create-enabled="true"
:manage-enabled="false"
:all-tags="allModels"
:is-loading="loadingModels"
:tags-by-id="modelsById"
:create-tag="addManualModel"
:create-tag-i18n-key="'settings.chatHub.providers.modal.edit.models.create'"
/>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.cancel') }}
</N8nButton>
<N8nButton type="primary" @click="onConfirm" :disabled="isConfirmDisabled">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.confirm') }}
</N8nButton>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
padding: var(--spacing--sm) 0 var(--spacing--md);
}
.container {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
gap: var(--spacing--2xs);
}
.toggle {
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.credentialContainer {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
}
.credentialPicker {
width: 100%;
}
.modelPicker {
width: 100%;
}
.footer {
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
}
.footerRight {
display: flex;
gap: var(--spacing--2xs);
}
</style>

View File

@ -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<CredentialsMap>(
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];
}),

View File

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

View File

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

View File

@ -12,6 +12,7 @@ const props = defineProps<{
appName: string;
credentialType: string;
selectedCredentialId: string | null;
hideCreateNew?: boolean;
}>();
const emit = defineEmits<{
@ -97,7 +98,7 @@ listenForModalChanges({
<template>
<div>
<div v-if="credentialOptions.length > 0" :class="$style.dropdown">
<div v-if="credentialOptions.length > 0 || props.hideCreateNew" :class="$style.dropdown">
<CredentialsDropdown
:credential-type="props.credentialType"
:credential-options="credentialOptions"
@ -121,7 +122,7 @@ listenForModalChanges({
</div>
<N8nButton
v-else
v-else-if="!props.hideCreateNew"
:label="`Create new ${props.appName} credential`"
data-test-id="create-credential"
@click="createNewCredential"

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { nextTick, ref } from 'vue';
export type CredentialOption = {
id: string;
name: string;
@ -20,22 +21,28 @@ const emit = defineEmits<{
const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
const selectRefs = ref<InstanceType<typeof N8nSelect> | null>(null);
const NEW_CREDENTIALS_TEXT = i18n.baseText('nodeCredentials.createNew');
const onCredentialSelected = (credentialId: string) => {
if (credentialId === NEW_CREDENTIALS_TEXT) {
emit('newCredential');
} else {
emit('credentialSelected', credentialId);
}
emit('credentialSelected', credentialId);
};
const onCreateNewCredential = async () => {
selectRefs.value?.blur();
await nextTick();
emit('newCredential');
};
</script>
<template>
<N8nSelect
ref="selectRefs"
size="small"
:model-value="props.selectedCredentialId"
@update:model-value="onCredentialSelected"
:popper-class="$style.selectPopper"
>
<N8nOption
v-for="item in props.credentialOptions"
@ -49,19 +56,68 @@ const onCredentialSelected = (credentialId: string) => {
<N8nText size="small">{{ item.typeDisplayName }}</N8nText>
</div>
</N8nOption>
<N8nOption
:key="NEW_CREDENTIALS_TEXT"
data-test-id="node-credentials-select-item-new"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</N8nOption>
<template #empty> </template>
<template #footer>
<button
type="button"
data-test-id="node-credentials-select-item-new"
:class="[$style.newCredential]"
@click="onCreateNewCredential()"
>
<N8nIcon size="xsmall" icon="plus" />
{{ NEW_CREDENTIALS_TEXT }}
</button>
</template>
</N8nSelect>
</template>
<style lang="scss" module>
.selectPopper {
:global(.el-select-dropdown__list) {
padding: 0;
}
:has(.newCredential:hover) :global(.hover) {
background-color: transparent;
}
&:not(:has(li)) .newCredential {
border-top: none;
box-shadow: none;
border-radius: var(--radius);
}
}
.credentialOption {
display: flex;
flex-direction: column;
}
.newCredential {
display: flex;
width: 100%;
gap: var(--spacing--3xs);
align-items: center;
font-weight: var(--font-weight--bold);
padding: var(--spacing--xs) var(--spacing--md);
background-color: var(--color--background--light-2);
color: var(--color--text--shade-1);
border: 0;
border-top: var(--border);
box-shadow: var(--shadow--light);
clip-path: inset(-12px 0 0 0); // Only show box shadow on top
&:not([disabled]) {
cursor: pointer;
&:hover {
color: var(--color--primary);
}
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View File

@ -4,7 +4,7 @@ import { onClickOutside } from '@vueuse/core';
import type { ITag } from '@n8n/rest-api-client/api/tags';
import { MAX_TAG_NAME_LENGTH } from '../tags.constants';
import type { EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { v4 as uuid } from 'uuid';
import { useToast } from '@/app/composables/useToast';
@ -21,6 +21,7 @@ interface TagsDropdownProps {
manageEnabled?: boolean;
createTag?: (name: string) => Promise<ITag>;
multipleLimit?: number;
createTagI18nKey?: BaseTextKey;
}
const i18n = useI18n();
@ -35,6 +36,7 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
manageEnabled: true,
createTag: undefined,
multipleLimit: 0,
createTagI18nKey: 'tagsDropdown.createTag',
});
const emit = defineEmits<{
@ -240,7 +242,7 @@ onClickOutside(
>
<N8nIcon icon="circle-plus" />
<span>
{{ i18n.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
{{ i18n.baseText(props.createTagI18nKey, { interpolate: { filter } }) }}
</span>
</N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled>