mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
feat(core): Add Chat settings admin view (no-changelog) (#22009)
This commit is contained in:
parent
731024c941
commit
24a4de8cf9
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export {
|
|||
ChatHubUpdateAgentRequest,
|
||||
type EnrichedStructuredChunk,
|
||||
type ChatHubAgentTool,
|
||||
UpdateChatSettingsRequest,
|
||||
type ChatProviderSettingsDto,
|
||||
} from './chat-hub';
|
||||
|
||||
export type { Collaborator } from './push/collaboration';
|
||||
|
|
|
|||
|
|
@ -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}%`) });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class UpdateChatSettingsDto extends Z.class({
|
||||
chatAccessEnabled: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -290,7 +290,7 @@ const selectColumn: ColumnDef<T> = {
|
|||
},
|
||||
meta: {
|
||||
cellProps: {
|
||||
align: undefined,
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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];
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user