feat(core): Add new Chat hub providers (no-changelog) (#21946)

This commit is contained in:
Jaakko Husso 2025-11-18 12:02:20 +02:00 committed by GitHub
parent 4ac0d11486
commit 7cd871b234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 420 additions and 43 deletions

View File

@ -11,7 +11,13 @@ import { Z } from 'zod-class';
/**
* Supported AI model providers
*/
export const chatHubLLMProviderSchema = z.enum(['openai', 'anthropic', 'google']);
export const chatHubLLMProviderSchema = z.enum([
'openai',
'anthropic',
'google',
'azureOpenAi',
'ollama',
]);
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
export const chatHubProviderSchema = z.enum([
@ -32,6 +38,8 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<
openai: 'openAiApi',
anthropic: 'anthropicApi',
google: 'googlePalmApi',
ollama: 'ollamaApi',
azureOpenAi: 'azureOpenAiApi',
};
export type ChatHubAgentTool = typeof JINA_AI_TOOL_NODE_TYPE | typeof SEAR_XNG_TOOL_NODE_TYPE;
@ -54,6 +62,16 @@ const googleModelSchema = z.object({
model: z.string(),
});
const azureOpenAIModelSchema = z.object({
provider: z.literal('azureOpenAi'),
model: z.string(),
});
const ollamaModelSchema = z.object({
provider: z.literal('ollama'),
model: z.string(),
});
const n8nModelSchema = z.object({
provider: z.literal('n8n'),
workflowId: z.string(),
@ -68,6 +86,8 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
openAIModelSchema,
anthropicModelSchema,
googleModelSchema,
azureOpenAIModelSchema,
ollamaModelSchema,
n8nModelSchema,
chatAgentSchema,
]);
@ -75,7 +95,14 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
export type ChatHubOpenAIModel = z.infer<typeof openAIModelSchema>;
export type ChatHubAnthropicModel = z.infer<typeof anthropicModelSchema>;
export type ChatHubGoogleModel = z.infer<typeof googleModelSchema>;
export type ChatHubBaseLLMModel = ChatHubOpenAIModel | ChatHubAnthropicModel | ChatHubGoogleModel;
export type ChatHubAzureOpenAIModel = z.infer<typeof azureOpenAIModelSchema>;
export type ChatHubOllamaModel = z.infer<typeof ollamaModelSchema>;
export type ChatHubBaseLLMModel =
| ChatHubOpenAIModel
| ChatHubAnthropicModel
| ChatHubGoogleModel
| ChatHubAzureOpenAIModel
| ChatHubOllamaModel;
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
@ -114,6 +141,8 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
openai: { models: [] },
anthropic: { models: [] },
google: { models: [] },
azureOpenAi: { models: [] },
ollama: { models: [] },
n8n: { models: [] },
// eslint-disable-next-line @typescript-eslint/naming-convention
'custom-agent': { models: [] },

View File

@ -448,6 +448,25 @@ export class ChatHubWorkflowService {
options: {},
},
};
case 'azureOpenAi':
return {
...common,
parameters: {
model: { __rl: true, mode: 'id', value: model },
options: {},
},
};
case 'ollama': {
return {
...common,
parameters: {
model: { __rl: true, mode: 'id', value: model },
options: {},
},
};
}
default:
throw new OperationalError('Unsupported model provider');
}
}

View File

@ -24,6 +24,14 @@ export const PROVIDER_NODE_TYPE_MAP: Record<ChatHubLLMProvider, INodeTypeNameVer
name: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
version: 1.2,
},
ollama: {
name: '@n8n/n8n-nodes-langchain.lmOllama',
version: 1,
},
azureOpenAi: {
name: '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
version: 1,
},
};
export const NODE_NAMES = {

View File

@ -155,6 +155,10 @@ export class ChatHubService {
return await this.fetchAnthropicModels(credentials, additionalData);
case 'google':
return await this.fetchGoogleModels(credentials, additionalData);
case 'ollama':
return await this.fetchOllamaModels(credentials, additionalData);
case 'azureOpenAi':
return await this.fetchAzureOpenAiModels(credentials, additionalData);
case 'n8n':
return await this.fetchAgentWorkflowsAsModels(user);
case 'custom-agent':
@ -281,6 +285,76 @@ export class ChatHubService {
};
}
private async fetchOllamaModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse['ollama']> {
const results = await this.nodeParametersService.getOptionsViaLoadOptions(
{
// From Ollama Model node
// https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LMOllama/description.ts#L24
routing: {
request: {
method: 'GET',
url: '/api/tags',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'models',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
additionalData,
PROVIDER_NODE_TYPE_MAP.ollama,
{},
credentials,
);
return {
models: results.map((result) => ({
name: String(result.value),
description: result.description ?? null,
model: {
provider: 'ollama',
model: String(result.value),
},
createdAt: null,
updatedAt: null,
})),
};
}
private async fetchAzureOpenAiModels(
_credentials: INodeCredentials,
_additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse['azureOpenAi']> {
// Azure doesn't appear to offer a way to list available models via API.
// If we add support for this in the future on the Azure OpenAI node we should copy that
// implementation here too.
return {
models: [],
};
}
private async fetchAgentWorkflowsAsModels(user: User): Promise<ChatModelsResponse['n8n']> {
const nodeTypes = [CHAT_TRIGGER_NODE_TYPE];
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(

View File

@ -137,6 +137,8 @@ export const maxContextWindowTokens: Record<ChatHubLLMProvider, Record<string, n
'models/imagen-4.0-ultra-generate-preview-06-06': 480,
'models/learnlm-2.0-flash-experimental': 0,
},
azureOpenAi: {},
ollama: {},
};
export const getMaxContextWindowTokens = (

View File

@ -299,6 +299,8 @@
"chat.window.session.resetSession": "Reset chat session",
"chatHub.agent.customAgents": "Custom Agents",
"chatHub.agent.newAgent": "New Agent",
"chatHub.agent.configureCredentials": "Configure credentials",
"chatHub.agent.addModel": "Add model",
"chatHub.agent.editor.title.new": "New Agent",
"chatHub.agent.editor.title.edit": "Edit Agent",
"chatHub.agent.editor.name.label": "Name",
@ -332,6 +334,15 @@
"chatHub.tools.editor.selectedCount": "{count} tool selected | {count} tools selected",
"chatHub.tools.editor.confirm": "Confirm",
"chatHub.tools.editor.cancel": "Cancel",
"chatHub.credentials.selector.title": "Select {provider} credential",
"chatHub.credentials.selector.chooseOrCreate": "Choose or create a credential for {provider}",
"chatHub.credentials.selector.createNew": "Create new",
"chatHub.credentials.selector.confirm": "Select",
"chatHub.credentials.selector.cancel": "Cancel",
"chatHub.models.byIdSelector.title": "Choose {provider} model by ID",
"chatHub.models.byIdSelector.choose": "Enter model identifier (e.g. \"gpt-4\")",
"chatHub.models.byIdSelector.confirm": "Select",
"chatHub.models.byIdSelector.cancel": "Cancel",
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
"chatEmbed.infoTip.link": "More info",
"chatEmbed.title": "Embed Chat in your website",

View File

@ -704,7 +704,27 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
function getAgent(model: ChatHubConversationModel) {
if (!agents.value) return;
return agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model));
const agent = agents.value[model.provider].models.find((agent) => isMatchedAgent(agent, model));
if (!agent) {
if (model.provider === 'custom-agent' || model.provider === 'n8n') {
return;
}
// Allow custom models chosen by ID even if they are not in the fetched list
return {
model: {
provider: model.provider,
model: model.model,
},
name: model.model,
description: null,
createdAt: null,
updatedAt: null,
};
}
return agent;
}
return {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { N8nButton, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { N8nButton, N8nHeading, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
@ -8,23 +8,25 @@ import { createEventBus } from '@n8n/utils/event-bus';
import { type ChatHubLLMProvider, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
provider: ChatHubLLMProvider;
initialValue: string | null;
}>();
const emit = defineEmits<{
select: [provider: ChatHubLLMProvider, credentialId: string];
createNew: [provider: ChatHubLLMProvider];
modalName: string;
data: {
provider: ChatHubLLMProvider;
initialValue: string | null;
onSelect: (provider: ChatHubLLMProvider, credentialId: string) => void;
onCreateNew: (provider: ChatHubLLMProvider) => void;
};
}>();
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const modalBus = ref(createEventBus());
const selectedCredentialId = ref<string | null>(props.initialValue);
const selectedCredentialId = ref<string | null>(props.data.initialValue);
const availableCredentials = computed<ICredentialsResponse[]>(() => {
return credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[props.provider]);
return credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider]);
});
function onCredentialSelect(credentialId: string) {
@ -33,13 +35,13 @@ function onCredentialSelect(credentialId: string) {
function onConfirm() {
if (selectedCredentialId.value) {
emit('select', props.provider, selectedCredentialId.value);
props.data.onSelect(props.data.provider, selectedCredentialId.value);
modalBus.value.emit('close');
}
}
function onCreateNew() {
emit('createNew', props.provider);
props.data.onCreateNew(props.data.provider);
modalBus.value.emit('close');
}
@ -50,7 +52,7 @@ function onCancel() {
<template>
<Modal
name="chatCredentialSelector"
:name="modalName"
:event-bus="modalBus"
width="50%"
:center="true"
@ -60,17 +62,31 @@ function onCancel() {
<template #header>
<div :class="$style.header">
<CredentialIcon
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[provider]"
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[data.provider]"
:size="24"
:class="$style.icon"
/>
<h2 :class="$style.title">Select {{ providerDisplayNames[provider] }} Credential</h2>
<N8nHeading size="medium" tag="h2" :class="$style.title">
{{
i18n.baseText('chatHub.credentials.selector.title', {
interpolate: {
provider: providerDisplayNames[data.provider],
},
})
}}
</N8nHeading>
</div>
</template>
<template #content>
<div :class="$style.content">
<N8nText size="small" color="text-base">
Choose an existing credential or create a new one
{{
i18n.baseText('chatHub.credentials.selector.chooseOrCreate', {
interpolate: {
provider: providerDisplayNames[data.provider],
},
})
}}
</N8nText>
<N8nSelect
:model-value="selectedCredentialId"
@ -89,11 +105,15 @@ function onCancel() {
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="secondary" @click="onCreateNew"> Create New </N8nButton>
<N8nButton type="secondary" @click="onCreateNew">
{{ i18n.baseText('chatHub.credentials.selector.createNew') }}
</N8nButton>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel"> Cancel </N8nButton>
<N8nButton type="tertiary" @click="onCancel">
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
Select
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
</N8nButton>
</div>
</div>
@ -102,12 +122,6 @@ function onCancel() {
</template>
<style lang="scss" module>
.title {
font-size: var(--font-size--lg);
line-height: var(--line-height--md);
margin: 0;
}
.content {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { N8nButton, N8nFormInput, N8nHeading, N8nText } from '@n8n/design-system';
import Modal from '@/app/components/Modal.vue';
import { createEventBus } from '@n8n/utils/event-bus';
import { type ChatHubLLMProvider, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import {
CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY,
providerDisplayNames,
} from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
modalName: string;
data: {
provider: ChatHubLLMProvider;
initialValue: string | null;
onSelect: (provider: ChatHubLLMProvider, modelId: string) => void;
};
}>();
const modalBus = ref(createEventBus());
const modelId = ref<string | null>(props.data.initialValue);
const inputRef = ref<InstanceType<typeof N8nFormInput> | null>(null);
const i18n = useI18n();
onMounted(() => {
// With modals normal focusing via `props.focus-initially` on N8nFormInput does not work
setTimeout(() => {
inputRef.value?.inputRef?.select();
inputRef.value?.inputRef?.focus();
});
});
function onConfirm() {
if (modelId.value) {
props.data.onSelect(props.data.provider, modelId.value);
modalBus.value.emit('close');
}
}
function onCancel() {
modalBus.value.emit('close');
}
</script>
<template>
<Modal
:name="CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY"
:event-bus="modalBus"
width="50%"
:center="true"
max-width="460px"
min-height="250px"
>
<template #header>
<div :class="$style.header">
<CredentialIcon
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[data.provider]"
:size="24"
:class="$style.icon"
/>
<N8nHeading size="medium" tag="h2" :class="$style.title">
{{
i18n.baseText('chatHub.models.byIdSelector.title', {
interpolate: {
provider: providerDisplayNames[data.provider],
},
})
}}
</N8nHeading>
</div>
</template>
<template #content>
<div :class="$style.content">
<N8nText size="small" color="text-base">
{{ i18n.baseText('chatHub.models.byIdSelector.choose') }}
</N8nText>
<N8nFormInput
ref="inputRef"
v-model="modelId"
name="model"
label=""
max-length="64"
focus-initially
@enter="onConfirm"
/>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="tertiary" @click="onCancel">
{{ i18n.baseText('chatHub.models.byIdSelector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!modelId" @click="onConfirm">
{{ i18n.baseText('chatHub.models.byIdSelector.confirm') }}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding: var(--spacing--sm) 0;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.icon {
flex-shrink: 0;
flex-grow: 0;
}
</style>

View File

@ -13,13 +13,16 @@ import type {
ChatModelDto,
ChatModelsResponse,
} from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import {
CHAT_CREDENTIAL_SELECTOR_MODAL_KEY,
CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY,
providerDisplayNames,
} from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { onClickOutside } from '@vueuse/core';
import { useI18n } from '@n8n/i18n';
import type { CredentialsMap } from '../chat.types';
import CredentialSelectorModal from './CredentialSelectorModal.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
@ -55,10 +58,22 @@ function handleSelectCredentials(provider: ChatHubProvider, id: string) {
emit('selectCredential', provider, id);
}
function handleSelectModelById(provider: ChatHubLLMProvider, modelId: string) {
emit('change', {
model: {
provider,
model: modelId,
},
name: modelId,
description: null,
updatedAt: null,
createdAt: null,
});
}
const i18n = useI18n();
const agents = ref<ChatModelsResponse>(emptyChatModelsResponse);
const dropdownRef = useTemplateRef('dropdownRef');
const credentialSelectorProvider = ref<ChatHubLLMProvider | null>(null);
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const telemetry = useTelemetry();
@ -119,10 +134,16 @@ 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,
},
{
id: `${provider}::configure`,
icon: 'settings',
title: 'Configure credentials...',
title: i18n.baseText('chatHub.agent.configureCredentials'),
disabled: false,
},
]);
@ -148,8 +169,26 @@ function openCredentialsSelectorOrCreate(provider: ChatHubLLMProvider) {
return;
}
credentialSelectorProvider.value = provider;
uiStore.openModal('chatCredentialSelector');
uiStore.openModalWithData({
name: CHAT_CREDENTIAL_SELECTOR_MODAL_KEY,
data: {
provider,
initialValue: credentials?.[provider] ?? null,
onSelect: handleSelectCredentials,
onCreateNew: handleCreateNewCredential,
},
});
}
function openModelByIdSelector(provider: ChatHubLLMProvider) {
uiStore.openModalWithData({
name: CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY,
data: {
provider,
initialValue: null,
onSelect: handleSelectModelById,
},
});
}
function onSelect(id: string) {
@ -174,6 +213,15 @@ function onSelect(id: string) {
return;
}
if (
identifier === 'add-model' &&
parsedModel.provider !== 'n8n' &&
parsedModel.provider !== 'custom-agent'
) {
openModelByIdSelector(parsedModel.provider);
return;
}
const selected = agents.value[parsedModel.provider].models.find((a) =>
isMatchedAgent(a, parsedModel),
);
@ -242,15 +290,6 @@ defineExpose({
</template>
<N8nButton :class="$style.dropdownButton" type="secondary" text>
<CredentialSelectorModal
v-if="credentialSelectorProvider"
:key="credentialSelectorProvider"
:provider="credentialSelectorProvider"
:initial-value="credentials?.[credentialSelectorProvider] ?? null"
@select="handleSelectCredentials"
@create-new="handleCreateNewCredential"
/>
<ChatAgentAvatar
v-if="selectedAgent"
:agent="selectedAgent"

View File

@ -11,6 +11,8 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
azureOpenAi: 'Azure OpenAI',
ollama: 'Ollama',
n8n: 'n8n',
'custom-agent': 'Custom Agent',
};
@ -19,3 +21,5 @@ export const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
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';

View File

@ -5,6 +5,8 @@ import {
CHAT_AGENTS_VIEW,
TOOLS_SELECTOR_MODAL_KEY,
AGENT_EDITOR_MODAL_KEY,
CHAT_CREDENTIAL_SELECTOR_MODAL_KEY,
CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY,
} from '@/features/ai/chatHub/constants';
const ChatSidebar = async () => await import('@/features/ai/chatHub/components/ChatSidebar.vue');
@ -40,6 +42,31 @@ export const ChatModule: FrontendModuleDescription = {
},
},
},
{
key: CHAT_CREDENTIAL_SELECTOR_MODAL_KEY,
component: async () => await import('./components/CredentialSelectorModal.vue'),
initialState: {
open: false,
data: {
provider: null,
initialValue: null,
onSelect: () => {},
onCreateNew: () => {},
},
},
},
{
key: CHAT_MODEL_BY_ID_SELECTOR_MODAL_KEY,
component: async () => await import('./components/ModelByIdSelectorModal.vue'),
initialState: {
open: false,
data: {
provider: null,
initialValue: null,
onSelect: () => {},
},
},
},
],
routes: [
{