feat(editor): Chat agent directory (no-changelog) (#21236)

This commit is contained in:
Suguru Inoue 2025-10-28 14:29:42 +01:00 committed by GitHub
parent 9767afde19
commit 3b36f7341d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 940 additions and 335 deletions

View File

@ -7,7 +7,7 @@ import { getInitials } from '../../utils/labelUtil';
interface AvatarProps {
firstName?: string | null;
lastName?: string | null;
size?: 'xsmall' | 'small' | 'medium' | 'large';
size?: 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large';
colors?: string[];
}
@ -34,6 +34,7 @@ const getColors = (colors: string[]): string[] => {
};
const sizes: { [size: string]: number } = {
xxsmall: 16,
xsmall: 20,
small: 28,
large: 48,
@ -86,6 +87,7 @@ const getSize = (size: string): number => sizes[size];
text-transform: uppercase;
}
.text-xxsmall,
.text-xsmall {
font-size: 6px;
}

View File

@ -43,6 +43,7 @@ const sizesInPixels: Record<IconSize, number> = {
medium: 14,
large: 16,
xlarge: 20,
xxlarge: 40,
};
const size = computed((): { height: string; width: string } => {

View File

@ -1,6 +1,6 @@
import type { TextColor } from '@n8n/design-system/types/text';
const ICON_SIZE = ['xsmall', 'small', 'medium', 'large', 'xlarge'] as const;
const ICON_SIZE = ['xsmall', 'small', 'medium', 'large', 'xlarge', 'xxlarge'] as const;
export type IconSize = (typeof ICON_SIZE)[number];
export type IconColor = TextColor;

View File

@ -0,0 +1,294 @@
<script setup lang="ts">
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import {
N8nButton,
N8nIcon,
N8nIconButton,
N8nInput,
N8nOption,
N8nSelect,
N8nText,
} from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import AgentEditorModal from '@/features/ai/chatHub/components/AgentEditorModal.vue';
import ChatAgentCard from '@/features/ai/chatHub/components/ChatAgentCard.vue';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
import { useUsersStore } from '@/features/settings/users/users.store';
import { type ChatHubConversationModel } from '@n8n/api-types';
import { filterAndSortAgents } from '@/features/ai/chatHub/chat.utils';
import type { ChatAgentFilter } from '@/features/ai/chatHub/chat.types';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { useMediaQuery } from '@vueuse/core';
import { MOBILE_MEDIA_QUERY } from '@/features/ai/chatHub/constants';
const chatStore = useChatStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const toast = useToast();
const message = useMessage();
const usersStore = useUsersStore();
const sidebar = useChatHubSidebarState();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const editingAgentId = ref<string | undefined>(undefined);
const agentFilter = ref<ChatAgentFilter>({
search: '',
provider: '',
sortBy: 'updatedAt',
});
const { credentialsByProvider } = useChatCredentials(usersStore.currentUserId ?? 'anonymous');
const allModels = computed(() =>
chatStore.agents
.map<ChatHubConversationModel>((agent) => ({
provider: 'custom-agent',
agentId: agent.id,
name: agent.name,
}))
.concat(
(chatStore.models?.n8n?.models ?? []).flatMap((model) =>
model.provider === 'n8n' ? [{ ...model, type: 'n8n-workflow' }] : [],
),
),
);
const models = computed(() => {
return filterAndSortAgents(
allModels.value,
agentFilter.value,
chatStore.agents,
workflowsStore.workflowsById, // TODO: ensure workflows are fetched
);
});
const providerOptions = [
{ label: 'All', value: '' },
{ label: 'Custom agents', value: 'custom-agent' },
{ label: 'n8n workflows', value: 'n8n' },
] as const;
const sortOptions = [
{ label: 'Sort by last updated', value: 'updatedAt' },
{ label: 'Sort by created', value: 'createdAt' },
];
function handleCreateAgent() {
chatStore.currentEditingAgent = null;
editingAgentId.value = undefined;
uiStore.openModal('agentEditor');
}
async function handleEditAgent(model: ChatHubConversationModel) {
if (model.provider !== 'custom-agent') {
return;
}
try {
await chatStore.fetchAgent(model.agentId);
editingAgentId.value = model.agentId;
uiStore.openModal('agentEditor');
} catch (error) {
toast.showError(error, 'Failed to load agent');
}
}
function handleCloseAgentEditor() {
editingAgentId.value = undefined;
}
async function handleAgentCreatedOrUpdated() {
await chatStore.fetchAgents();
editingAgentId.value = undefined;
}
async function handleDeleteAgent(agentId: string) {
const confirmed = await message.confirm(
'Are you sure you want to delete this agent?',
'Delete agent',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
},
);
if (confirmed !== MODAL_CONFIRM) {
return;
}
try {
await chatStore.deleteAgent(agentId);
toast.showMessage({ type: 'success', title: 'Agent deleted successfully' });
} catch (error) {
toast.showError(error, 'Could not delete the agent');
}
}
onMounted(async () => {
await Promise.all([
chatStore.fetchAgents(),
chatStore.fetchChatModels(credentialsByProvider.value),
]);
});
</script>
<template>
<div :class="[$style.container, { [$style.isMobileDevice]: isMobileDevice }]">
<div :class="$style.header">
<div :class="$style.headerContent">
<N8nText tag="h1" size="xlarge" bold>Custom Agents</N8nText>
<N8nText color="text-light">
Use n8n workflow agents or create custom AI agents with specific instructions and
behaviors
</N8nText>
</div>
<N8nButton icon="plus" type="primary" size="large" @click="handleCreateAgent">
New Agent
</N8nButton>
</div>
<div v-if="allModels.length > 0" :class="$style.controls">
<N8nInput v-model="agentFilter.search" :class="$style.search" placeholder="Search" clearable>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<N8nSelect v-model="agentFilter.provider" :class="$style.filter">
<N8nOption
v-for="option in providerOptions"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
<N8nSelect v-model="agentFilter.sortBy" :class="$style.sort" placeholder="Sort by">
<N8nOption
v-for="option in sortOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</N8nSelect>
</div>
<div v-if="allModels.length === 0" :class="$style.empty">
<N8nText color="text-light" size="medium">
No agents available. Create your first custom agent to get started.
</N8nText>
</div>
<div v-else-if="models.length === 0" :class="$style.empty">
<N8nText color="text-light" size="medium"> No agents match your search criteria. </N8nText>
</div>
<div v-else :class="$style.agentsGrid">
<ChatAgentCard
v-for="model in models"
:key="`${model.provider}::${model.provider === 'custom-agent' ? model.agentId : model.provider === 'n8n' ? model.workflowId : model.model}`"
:model="model"
:agents="chatStore.agents"
:workflows-by-id="workflowsStore.workflowsById"
@edit="handleEditAgent(model)"
@delete="model.provider === 'custom-agent' ? handleDeleteAgent(model.agentId) : undefined"
/>
</div>
<AgentEditorModal
:agent-id="editingAgentId"
:credentials="credentialsByProvider"
@create-agent="handleAgentCreatedOrUpdated"
@close="handleCloseAgentEditor"
/>
<N8nIconButton
v-if="!sidebar.isStatic.value"
:class="$style.menuButton"
type="secondary"
icon="panel-left"
text
icon-size="large"
@click="sidebar.toggleOpen(true)"
/>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: var(--content-container-width);
padding: var(--spacing--xl);
gap: var(--spacing--xl);
overflow-y: auto;
position: relative;
}
.menuButton {
position: fixed;
top: 0;
left: 0;
margin: var(--spacing--sm);
.isMobileDevice & {
margin: var(--spacing--2xs);
}
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing--lg);
width: 100%;
}
.headerContent {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.controls {
display: flex;
gap: var(--spacing--sm);
align-items: center;
}
.search {
flex: 1;
min-width: 200px;
}
.filter {
width: 200px;
}
.sort {
width: 200px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
flex: 1;
width: 100%;
}
.agentsGrid {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
</style>

View File

@ -1,9 +1,6 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
import {
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS,
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
} from '@/constants';
import { LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL } from '@/constants';
import { findOneFromModelsResponse } from '@/features/ai/chatHub/chat.utils';
import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConversationHeader.vue';
import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue';
@ -15,33 +12,30 @@ import {
CHAT_VIEW,
MOBILE_MEDIA_QUERY,
} from '@/features/ai/chatHub/constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import {
chatHubConversationModelSchema,
type ChatHubProvider,
type ChatHubLLMProvider,
chatHubProviderSchema,
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubConversationModel,
type ChatHubMessageDto,
type ChatMessageId,
type ChatHubSendMessageRequest,
} from '@n8n/api-types';
import { N8nIconButton, N8nScrollArea } from '@n8n/design-system';
import { useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core';
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useChatStore } from './chat.store';
import { credentialsMapSchema, type CredentialsMap } from './chat.types';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useUIStore } from '@/stores/ui.store';
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
const router = useRouter();
const route = useRoute();
const usersStore = useUsersStore();
const chatStore = useChatStore();
const credentialsStore = useCredentialsStore();
const toast = useToast();
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
const documentTitle = useDocumentTitle();
@ -136,80 +130,40 @@ const selectedModel = computed<ChatHubConversationModel | null>(() => {
return model;
});
const selectedCredentials = useLocalStorage<CredentialsMap>(
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(usersStore.currentUserId ?? 'anonymous'),
{},
{
writeDefaults: false,
shallow: true,
serializer: {
read: (value) => {
try {
return credentialsMapSchema.parse(JSON.parse(value));
} catch (error) {
return {};
}
},
write: (value) => JSON.stringify(value),
},
},
const { credentialsByProvider, selectCredential } = useChatCredentials(
usersStore.currentUserId ?? 'anonymous',
);
const autoSelectCredentials = computed<CredentialsMap>(() =>
Object.fromEntries(
chatHubProviderSchema.options.map((provider) => {
if (provider === 'n8n' || provider === 'custom-agent') {
return [provider, null];
}
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
if (!credentialType) {
return [provider, null];
}
const lastCreatedCredential =
credentialsStore
.getCredentialsByType(credentialType)
.toSorted((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0]?.id ?? null;
return [provider, lastCreatedCredential];
}),
),
);
const mergedCredentials = computed(() => ({
...autoSelectCredentials.value,
...selectedCredentials.value,
}));
const chatMessages = computed(() => chatStore.getActiveMessages(sessionId.value));
const isNewChat = computed(() => route.name === CHAT_VIEW);
const credentialsId = computed(() =>
selectedModel.value ? mergedCredentials.value[selectedModel.value.provider] : undefined,
const credentialsForSelectedProvider = computed<ChatHubSendMessageRequest['credentials'] | null>(
() => {
if (!selectedModel.value) {
return null;
}
if (selectedModel.value.provider === 'custom-agent' || selectedModel.value.provider === 'n8n') {
return {};
}
const credentialsId = credentialsByProvider.value[selectedModel.value.provider];
if (!credentialsId) {
return null;
}
return {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId,
name: '',
},
};
},
);
const modelRequiresCredentials = computed(() => {
if (!selectedModel.value) return false;
return (
selectedModel.value?.provider !== 'n8n' && selectedModel.value?.provider !== 'custom-agent'
);
});
const isMissingSelectedCredential = computed(() => {
if (!selectedModel.value) return false;
if (!modelRequiresCredentials.value) {
return false;
}
return !credentialsId.value;
});
const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvider.value);
const editingMessageId = ref<string>();
const didSubmitInCurrentSession = ref(false);
const credentialsFetched = ref<boolean>(false);
const modelsFetched = ref<boolean>(false);
const editingAgentId = ref<string | undefined>(undefined);
function scrollToBottom(smooth: boolean) {
@ -251,28 +205,20 @@ watch(
{ immediate: true, flush: 'post' },
);
// Reload models when credentials are updated
// TODO: fix duplicate requests
// Preselect a model
watch(
mergedCredentials,
async (credentials) => {
const models = await chatStore.fetchChatModels(credentials);
() => chatStore.models,
(models) => {
const selected = selectedModel.value;
if (selected === null) {
const model = findOneFromModelsResponse(models) ?? null;
if (model) {
await handleSelectModel(model);
}
if (!models || selected !== null) {
return;
}
const currentProvider = selectedModel.value?.provider;
if (currentProvider && currentProvider !== 'n8n') {
const providerModels = models[currentProvider].models;
modelsFetched.value = !models[currentProvider].error && providerModels.length > 0;
} else {
modelsFetched.value = true;
const model = findOneFromModelsResponse(models) ?? null;
if (model) {
void handleSelectModel(model);
}
},
{ immediate: true },
@ -305,41 +251,67 @@ watch(
{ immediate: true },
);
onMounted(async () => {
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
credentialsFetched.value = true;
});
// TODO: wait for agents to be fetched
// Handle agent/workflow pre-selection from URL query parameters
watch(
() => [route.query.agentId, route.query.workflowId],
async ([agentId, workflowId]) => {
if (!isNewSession.value) {
return;
}
// If both are specified, remove both query params
if (agentId && workflowId) {
await router.replace({ query: {} });
return;
}
// Handle custom agent selection
if (agentId) {
const agent = chatStore.agents.find((a) => a.id === agentId);
if (agent) {
await handleSelectModel({
provider: 'custom-agent',
agentId: agent.id,
name: agent.name,
});
}
return;
}
// Handle n8n workflow selection
if (typeof workflowId === 'string') {
const n8nModel = chatStore.models?.n8n?.models.find(
(m) => m.provider === 'n8n' && m.workflowId === workflowId,
);
if (n8nModel) {
await handleSelectModel(n8nModel);
}
}
},
{ immediate: true },
);
function onSubmit(message: string) {
if (
!message.trim() ||
isResponding.value ||
!selectedModel.value ||
isMissingSelectedCredential.value
!credentialsForSelectedProvider.value
) {
return;
}
didSubmitInCurrentSession.value = true;
const credentials = {};
if (
selectedModel.value.provider !== 'n8n' &&
selectedModel.value.provider !== 'custom-agent' &&
credentialsId.value
) {
Object.assign(credentials, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId.value,
name: '',
},
});
}
chatStore.sendMessage(sessionId.value, message, selectedModel.value, credentials);
chatStore.sendMessage(
sessionId.value,
message,
selectedModel.value,
credentialsForSelectedProvider.value,
);
inputRef.value?.setText('');
@ -366,33 +338,19 @@ function handleEditMessage(message: ChatHubMessageDto) {
chatStore.isResponding(message.sessionId) ||
!['human', 'ai'].includes(message.type) ||
!selectedModel.value ||
isMissingSelectedCredential.value
!credentialsForSelectedProvider.value
) {
return;
}
const messageToEdit = message.revisionOfMessageId ?? message.id;
const credentials = {};
if (
selectedModel.value.provider !== 'n8n' &&
selectedModel.value.provider !== 'custom-agent' &&
credentialsId.value
) {
Object.assign(credentials, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId.value,
name: '',
},
});
}
chatStore.editMessage(
sessionId.value,
messageToEdit,
message.content,
selectedModel.value,
credentials,
credentialsForSelectedProvider.value,
);
editingMessageId.value = undefined;
}
@ -402,28 +360,19 @@ function handleRegenerateMessage(message: ChatHubMessageDto) {
chatStore.isResponding(message.sessionId) ||
message.type !== 'ai' ||
!selectedModel.value ||
isMissingSelectedCredential.value
!credentialsForSelectedProvider.value
) {
return;
}
const messageToRetry = message.retryOfMessageId ?? message.id;
const credentials = {};
if (
selectedModel.value.provider !== 'n8n' &&
selectedModel.value.provider !== 'custom-agent' &&
credentialsId.value
) {
Object.assign(credentials, {
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
id: credentialsId.value,
name: '',
},
});
}
chatStore.regenerateMessage(sessionId.value, messageToRetry, selectedModel.value, credentials);
chatStore.regenerateMessage(
sessionId.value,
messageToRetry,
selectedModel.value,
credentialsForSelectedProvider.value,
);
}
async function handleSelectModel(selection: ChatHubConversationModel) {
@ -438,10 +387,6 @@ async function handleSelectModel(selection: ChatHubConversationModel) {
}
}
function handleSelectCredentials(provider: ChatHubProvider, id: string) {
selectedCredentials.value = { ...selectedCredentials.value, [provider]: id };
}
function handleSwitchAlternative(messageId: string) {
chatStore.switchAlternative(sessionId.value, messageId);
}
@ -488,16 +433,16 @@ function closeAgentEditor() {
<ChatConversationHeader
ref="headerRef"
:selected-model="selectedModel"
:credentials="mergedCredentials"
:credentials="credentialsByProvider"
@select-model="handleSelectModel"
@edit-agent="handleEditAgent"
@create-agent="openNewAgentCreator"
@select-credential="handleSelectCredentials"
@select-credential="selectCredential"
/>
<AgentEditorModal
:agent-id="editingAgentId"
:credentials="mergedCredentials"
:credentials="credentialsByProvider"
@create-agent="handleSelectModel"
@close="closeAgentEditor"
/>

View File

@ -62,3 +62,9 @@ export interface GroupedConversations {
group: string;
sessions: ChatHubSessionDto[];
}
export interface ChatAgentFilter {
sortBy: 'updatedAt' | 'createdAt';
provider: 'custom-agent' | 'n8n' | '';
search: string;
}

View File

@ -3,8 +3,11 @@ import {
type ChatHubConversationModel,
type ChatModelsResponse,
type ChatHubSessionDto,
type ChatHubAgentDto,
} from '@n8n/api-types';
import type { GroupedConversations } from './chat.types';
import type { ChatMessage, GroupedConversations, ChatAgentFilter } from './chat.types';
import { CHAT_VIEW } from './constants';
import type { IWorkflowDb } from '@/Interface';
export function findOneFromModelsResponse(
response: ChatModelsResponse,
@ -18,8 +21,8 @@ export function findOneFromModelsResponse(
return undefined;
}
export function getRelativeDate(now: Date, dateString: string | null): string {
const date = dateString ? new Date(dateString) : now;
export function getRelativeDate(now: Date, dateString: string): string {
const date = new Date(dateString);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
@ -45,7 +48,7 @@ export function groupConversationsByDate(sessions: ChatHubSessionDto[]): Grouped
// Group sessions by relative date
for (const session of sessions) {
const group = getRelativeDate(now, session.lastMessageAt);
const group = getRelativeDate(now, session.lastMessageAt ?? session.updatedAt);
if (!groups.has(group)) {
groups.set(group, []);
@ -66,11 +69,159 @@ export function groupConversationsByDate(sessions: ChatHubSessionDto[]): Grouped
group: groupName,
sessions: sessions.sort(
(a, b) =>
(b.lastMessageAt ? Date.parse(b.lastMessageAt) : +now) -
(a.lastMessageAt ? Date.parse(a.lastMessageAt) : +now),
Date.parse(b.lastMessageAt ?? b.updatedAt) -
Date.parse(a.lastMessageAt ?? a.updatedAt),
),
},
]
: [];
});
}
export function getAgentRoute(model: ChatHubConversationModel) {
if (model.provider === 'n8n') {
return {
name: CHAT_VIEW,
query: {
workflowId: model.workflowId,
},
};
}
if (model.provider === 'custom-agent') {
return {
name: CHAT_VIEW,
query: {
agentId: model.agentId,
},
};
}
return {
name: CHAT_VIEW,
};
}
export function restoreConversationModelFromMessageOrSession(
messageOrSession: ChatHubSessionDto | ChatMessage,
agents: ChatHubAgentDto[],
workflowsById: Partial<Record<string, IWorkflowDb>>,
): ChatHubConversationModel | null {
if (messageOrSession.provider === null) {
return null;
}
switch (messageOrSession.provider) {
case 'custom-agent':
if (!messageOrSession.agentId) {
return null;
}
return {
provider: 'custom-agent',
agentId: messageOrSession.agentId,
name:
agents.find((agent) => agent.id === messageOrSession.agentId)?.name ??
`Custom agent ${messageOrSession.agentId}`,
};
case 'n8n':
if (!messageOrSession.workflowId) {
return null;
}
return {
provider: 'n8n',
workflowId: messageOrSession.workflowId,
name:
workflowsById[messageOrSession.workflowId]?.name ??
`n8n workflow ${messageOrSession.workflowId}`,
};
default:
if (messageOrSession.model === null) {
return null;
}
return {
provider: messageOrSession.provider,
model: messageOrSession.model,
name: messageOrSession.model,
};
}
}
export function describeConversationModel(model: ChatHubConversationModel) {
switch (model.provider) {
case 'n8n':
return `n8n workflow ${model.name}`;
case 'custom-agent':
return `Custom agent ${model.name}`;
default:
return model.model;
}
}
export function getTimestamp(
model: ChatHubConversationModel,
type: 'createdAt' | 'updatedAt',
agents: ChatHubAgentDto[],
workflowsById: Partial<Record<string, IWorkflowDb>>,
): number | null {
if (model.provider === 'custom-agent') {
const agent = agents.find((a) => a.id === model.agentId);
return agent?.[type] ? Date.parse(agent[type]) : null;
}
if (model.provider === 'n8n') {
const workflow = workflowsById[model.workflowId];
return workflow?.[type]
? typeof workflow[type] === 'string'
? Date.parse(workflow[type])
: workflow[type]
: null;
}
return null;
}
export function filterAndSortAgents(
models: ChatHubConversationModel[],
filter: ChatAgentFilter,
agents: ChatHubAgentDto[],
workflowsById: Partial<Record<string, IWorkflowDb>>,
): ChatHubConversationModel[] {
let filtered = models;
// Apply search filter
if (filter.search.trim()) {
const query = filter.search.toLowerCase();
filtered = filtered.filter((model) => model.name.toLowerCase().includes(query));
}
// Apply provider filter
if (filter.provider !== '') {
filtered = filtered.filter((model) => model.provider === filter.provider);
}
// Apply sorting
filtered = [...filtered].sort((a, b) => {
const dateA = getTimestamp(a, filter.sortBy, agents, workflowsById);
const dateB = getTimestamp(b, filter.sortBy, agents, workflowsById);
// Sort by dates (newest first)
if (dateA && dateB) {
return dateB - dateA;
}
// Items without dates go to the end
if (dateA && !dateB) {
return -1;
}
if (!dateA && dateB) {
return 1;
}
return 0;
});
return filtered;
}

View File

@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
import Modal from '@/components/Modal.vue';
import { createEventBus } from '@n8n/utils/event-bus';
import type { ChatHubConversationModel, ChatHubProvider } from '@n8n/api-types';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import { useUIStore } from '@/stores/ui.store';
import type { ChatHubConversationModel, ChatHubProvider } from '@n8n/api-types';
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, ref, watch } from 'vue';
import type { CredentialsMap } from '../chat.types';
const props = defineProps<{
@ -213,30 +213,30 @@ async function onDelete() {
min-height="400px"
>
<template #header>
<div :class="$style.header">
<h2 :class="$style.title">{{ title }}</h2>
</div>
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<div :class="$style.field">
<N8nText tag="label" size="small" bold :class="$style.label">
{{ i18n.baseText('chatHub.agent.editor.name.label') }}
<span :class="$style.required">*</span>
</N8nText>
<N8nInputLabel
input-name="agent-name"
:label="i18n.baseText('chatHub.agent.editor.name.label')"
:required="true"
>
<N8nInput
id="agent-name"
v-model="name"
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
:maxlength="128"
:class="$style.input"
/>
</div>
</N8nInputLabel>
<div :class="$style.field">
<N8nText tag="label" size="small" bold :class="$style.label">{{
i18n.baseText('chatHub.agent.editor.description.label')
}}</N8nText>
<N8nInputLabel
input-name="agent-description"
:label="i18n.baseText('chatHub.agent.editor.description.label')"
>
<N8nInput
id="agent-description"
v-model="description"
type="textarea"
:placeholder="i18n.baseText('chatHub.agent.editor.description.placeholder')"
@ -244,36 +244,36 @@ async function onDelete() {
:rows="3"
:class="$style.input"
/>
</div>
</N8nInputLabel>
<div :class="$style.field">
<N8nText tag="label" size="small" bold :class="$style.label">
{{ i18n.baseText('chatHub.agent.editor.systemPrompt.label') }}
<span :class="$style.required">*</span>
</N8nText>
<N8nInputLabel
input-name="agent-system-prompt"
:label="i18n.baseText('chatHub.agent.editor.systemPrompt.label')"
:required="true"
>
<N8nInput
id="agent-system-prompt"
v-model="systemPrompt"
type="textarea"
:placeholder="i18n.baseText('chatHub.agent.editor.systemPrompt.placeholder')"
:rows="6"
:class="$style.input"
/>
</div>
</N8nInputLabel>
<div :class="$style.field">
<N8nText tag="label" size="small" bold :class="$style.label">
{{ i18n.baseText('chatHub.agent.editor.model.label') }}
<span :class="$style.required">*</span>
</N8nText>
<N8nInputLabel
input-name="agent-model"
:label="i18n.baseText('chatHub.agent.editor.model.label')"
:required="true"
>
<ModelSelector
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:include-custom-agents="false"
:credentials="agentMergedCredentials"
@change="onModelChange"
@select-credential="onCredentialSelected"
/>
</div>
</N8nInputLabel>
</div>
</template>
<template #footer>
@ -297,18 +297,6 @@ async function onDelete() {
</template>
<style lang="scss" module>
.title {
font-size: var(--font-size--lg);
line-height: var(--line-height--md);
margin: 0;
}
.header {
display: flex;
gap: var(--spacing--2xs);
align-items: center;
}
.content {
display: flex;
flex-direction: column;
@ -316,20 +304,6 @@ async function onDelete() {
padding: var(--spacing--sm) 0;
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.label {
display: block;
}
.required {
color: var(--color--primary);
}
.input {
width: 100%;
}

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { describeConversationModel } from '@/features/ai/chatHub/chat.utils';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { type ChatHubConversationModel, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { N8nAvatar, N8nIcon, N8nTooltip } from '@n8n/design-system';
defineProps<{
model: ChatHubConversationModel;
size: 'sm' | 'md' | 'lg';
tooltip?: boolean;
}>();
</script>
<template>
<N8nTooltip :show-after="100" placement="left" :disabled="!tooltip">
<template #content>{{ describeConversationModel(model) }}</template>
<N8nAvatar
v-if="model.provider === 'custom-agent'"
:first-name="model.name"
:size="size === 'lg' ? 'medium' : size === 'sm' ? 'xxsmall' : 'xsmall'"
/>
<N8nIcon
v-else-if="model.provider === 'n8n'"
icon="robot"
:size="size === 'lg' ? 'xxlarge' : size === 'sm' ? 'large' : 'xlarge'"
/>
<CredentialIcon
v-else
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[model.provider]"
:size="size === 'sm' ? 16 : size === 'lg' ? 40 : 20"
/>
</N8nTooltip>
</template>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { getAgentRoute, getTimestamp } from '@/features/ai/chatHub/chat.utils';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import type { ChatHubAgentDto, ChatHubConversationModel } from '@n8n/api-types';
import type { IWorkflowDb } from '@/Interface';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import TimeAgo from '@/components/TimeAgo.vue';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
const { model, agents, workflowsById } = defineProps<{
model: ChatHubConversationModel;
agents: ChatHubAgentDto[];
workflowsById: Partial<Record<string, IWorkflowDb>>;
}>();
const emit = defineEmits<{
edit: [];
delete: [];
}>();
const description = computed(() => {
if (model.provider === 'custom-agent') {
const agent = agents.find((a) => a.id === model.agentId);
if (agent?.description) {
return agent.description;
}
}
return 'No description';
});
const updatedAt = computed(() => getTimestamp(model, 'updatedAt', agents, workflowsById));
const createdAt = computed(() => getTimestamp(model, 'createdAt', agents, workflowsById));
</script>
<template>
<RouterLink :to="getAgentRoute(model)" :class="$style.card">
<ChatAgentAvatar :model="model" size="lg" />
<div :class="$style.content">
<N8nText tag="h3" size="medium" bold :class="$style.title">
{{ model.name }}
</N8nText>
<N8nText size="small" color="text-light" :class="$style.description">
{{ description }}
</N8nText>
<div :class="$style.metadata">
<N8nText size="small" color="text-light">
{{ model.provider === 'n8n' ? 'n8n workflow' : 'Custom agent' }}
</N8nText>
<N8nText v-if="updatedAt" size="small" color="text-light">
Last updated <TimeAgo :date="String(updatedAt)" />
</N8nText>
<N8nText v-if="createdAt" size="small" color="text-light">
Created <TimeAgo :date="String(createdAt)" />
</N8nText>
</div>
</div>
<div v-if="model.provider === 'custom-agent'" :class="$style.actions">
<N8nIconButton
icon="pen"
type="tertiary"
size="medium"
title="Edit"
@click.prevent="emit('edit')"
/>
<N8nIconButton
icon="trash-2"
type="tertiary"
size="medium"
title="More options"
@click.prevent="emit('delete')"
/>
</div>
</RouterLink>
</template>
<style lang="scss" module>
.card {
display: flex;
align-items: center;
gap: var(--spacing--md);
padding: var(--spacing--md);
background-color: var(--color--background--light-3);
border: var(--border);
border-radius: var(--radius--lg);
text-decoration: none;
color: inherit;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--color--primary);
}
}
.avatar {
flex-shrink: 0;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
flex: 1;
min-width: 0;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metadata {
display: flex;
align-items: center;
& > * {
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& > *:not(:last-child):after {
content: '•';
display: block;
padding-inline: var(--spacing--3xs);
}
}
.actions {
display: flex;
gap: var(--spacing--2xs);
flex-shrink: 0;
}
</style>

View File

@ -1,5 +1,4 @@
<script setup lang="ts">
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import type { CredentialsMap } from '@/features/ai/chatHub/chat.types';
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
@ -23,7 +22,6 @@ const emit = defineEmits<{
}>();
const sidebar = useChatHubSidebarState();
const chatStore = useChatStore();
const router = useRouter();
const modelSelectorRef = useTemplateRef('modelSelectorRef');
@ -65,7 +63,6 @@ defineExpose({
/>
<ModelSelector
ref="modelSelectorRef"
:models="chatStore.models ?? null"
:selected-model="selectedModel"
:credentials="credentials"
@change="onModelChange"
@ -105,6 +102,9 @@ defineExpose({
.grow {
flex-grow: 1;
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.title {

View File

@ -1,21 +1,20 @@
<script setup lang="ts">
import { N8nIcon, N8nInput, N8nButton, N8nTooltip, N8nAvatar } from '@n8n/design-system';
import VueMarkdown from 'vue-markdown-render';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
import ChatMessageActions from './ChatMessageActions.vue';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useClipboard } from '@/composables/useClipboard';
import { ref, nextTick, watch, useTemplateRef, computed, onBeforeMount } from 'vue';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
import { PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
import { useChatHubMarkdownOptions } from '@/features/ai/chatHub/composables/useChatHubMarkdownOptions';
import { useSpeechSynthesis } from '@vueuse/core';
import type { ChatMessage } from '../chat.types';
import type { ChatMessageId } from '@n8n/api-types';
import { useChatStore } from '../chat.store';
const chatStore = useChatStore();
import { N8nButton, N8nIcon, N8nInput } from '@n8n/design-system';
import { useSpeechSynthesis } from '@vueuse/core';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import { computed, nextTick, onBeforeMount, ref, useTemplateRef, watch } from 'vue';
import VueMarkdown from 'vue-markdown-render';
import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
message: ChatMessage;
@ -37,6 +36,8 @@ const emit = defineEmits<{
}>();
const clipboard = useClipboard();
const chatStore = useChatStore();
const workflowsStore = useWorkflowsStore();
const editedText = ref('');
const textareaRef = useTemplateRef('textarea');
@ -50,31 +51,13 @@ const speech = useSpeechSynthesis(messageContent, {
volume: 1,
});
const credentialTypeName = computed(() => {
if (
message.type !== 'ai' ||
!message.provider ||
message.provider === 'n8n' ||
message.provider === 'custom-agent'
) {
return null;
}
return PROVIDER_CREDENTIAL_TYPE_MAP[message.provider] ?? null;
});
const isCustomAgent = computed(() => message.type === 'ai' && message.provider === 'custom-agent');
const agentName = computed(() => {
if (!isCustomAgent.value || !message.agentId) {
return null;
}
const agent = chatStore.getAgent(message.agentId);
// if agent was deleted, use cached name
// if agent was renamed, use updated name
return agent?.name ?? message.name;
});
const model = computed(() =>
restoreConversationModelFromMessageOrSession(
message,
chatStore.agents,
workflowsStore.workflowsById,
),
);
async function handleCopy() {
const text = message.content;
@ -162,15 +145,7 @@ onBeforeMount(() => {
>
<div :class="$style.avatar">
<N8nIcon v-if="message.type === 'human'" icon="user" width="20" height="20" />
<N8nAvatar v-else-if="isCustomAgent" :first-name="agentName" size="xsmall" />
<N8nTooltip
v-else-if="message.type === 'ai' && credentialTypeName"
:show-after="100"
placement="left"
>
<template #content>{{ message.model }}</template>
<CredentialIcon :size="20" :credential-type-name="credentialTypeName" />
</N8nTooltip>
<ChatAgentAvatar v-else-if="model" :model="model" size="md" tooltip />
<N8nIcon v-else icon="sparkles" width="20" height="20" />
</div>
<div :class="$style.content">

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
import { CHAT_CONVERSATION_VIEW } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubSessionDto } from '@n8n/api-types';
import { N8nIcon, N8nInput, N8nAvatar } from '@n8n/design-system';
import { type ChatHubSessionDto } from '@n8n/api-types';
import { N8nInput } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useChatStore } from '../chat.store';
const chatStore = useChatStore();
import { useWorkflowsStore } from '@/stores/workflows.store';
import { restoreConversationModelFromMessageOrSession } from '@/features/ai/chatHub/chat.utils';
const { session, isRenaming, active } = defineProps<{
session: ChatHubSessionDto;
@ -23,22 +23,20 @@ const emit = defineEmits<{
delete: [sessionId: string];
}>();
const chatStore = useChatStore();
const workflowsStore = useWorkflowsStore();
const input = useTemplateRef('input');
const editedLabel = ref('');
type SessionAction = 'rename' | 'delete';
const agentName = computed(() => {
if (!session.agentId) {
return null;
}
const agent = chatStore.getAgent(session.agentId);
// if agent was deleted, use cached name
// if agent was renamed, use updated name
return agent?.name ?? session.agentName;
});
const model = computed(() =>
restoreConversationModelFromMessageOrSession(
session,
chatStore.agents,
workflowsStore.workflowsById,
),
);
const dropdownItems = computed<Array<ActionDropdownItem<SessionAction>>>(() => [
{
@ -117,21 +115,7 @@ watch(
/>
</template>
<template #icon>
<N8nIcon
v-if="session.provider === null || session.provider === 'n8n'"
size="medium"
icon="message-circle"
/>
<N8nAvatar
v-else-if="session.provider === 'custom-agent'"
:first-name="agentName"
size="xsmall"
/>
<CredentialIcon
v-else
:credential-type-name="PROVIDER_CREDENTIAL_TYPE_MAP[session.provider]"
:size="16"
/>
<ChatAgentAvatar v-if="model" :model="model" size="sm" />
</template>
</ChatSidebarLink>
</template>

View File

@ -7,7 +7,7 @@ import { useChatStore } from '@/features/ai/chatHub/chat.store';
import { groupConversationsByDate } from '@/features/ai/chatHub/chat.utils';
import ChatSidebarLink from '@/features/ai/chatHub/components/ChatSidebarLink.vue';
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import { CHAT_VIEW, CHAT_AGENTS_VIEW } from '@/features/ai/chatHub/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system';
import Logo from '@n8n/design-system/components/N8nLogo';
@ -114,6 +114,13 @@ onMounted(async () => {
:active="route.name === CHAT_VIEW"
@click="sidebar.toggleOpen(false)"
/>
<ChatSidebarLink
:to="{ name: CHAT_AGENTS_VIEW }"
label="Custom Agents"
icon="robot"
:active="route.name === CHAT_AGENTS_VIEW"
@click="sidebar.toggleOpen(false)"
/>
</div>
<N8nScrollArea as-child type="scroll">
<div :class="$style.items">

View File

@ -1,14 +1,9 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { N8nNavigationDropdown, N8nIcon, N8nButton, N8nText, N8nAvatar } from '@n8n/design-system';
import { type ComponentProps } from 'vue-component-type-helpers';
import { PROVIDER_CREDENTIAL_TYPE_MAP, chatHubProviderSchema } from '@n8n/api-types';
import type {
ChatHubProvider,
ChatHubConversationModel,
ChatModelsResponse,
ChatHubLLMProvider,
} from '@n8n/api-types';
import type { ChatHubProvider, ChatHubConversationModel, ChatHubLLMProvider } from '@n8n/api-types';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { onClickOutside } from '@vueuse/core';
@ -18,10 +13,11 @@ import type { CredentialsMap } from '../chat.types';
import CredentialSelectorModal from './CredentialSelectorModal.vue';
import { useUIStore } from '@/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useChatStore } from '@/features/ai/chatHub/chat.store';
import ChatAgentAvatar from '@/features/ai/chatHub/components/ChatAgentAvatar.vue';
const props = withDefaults(
defineProps<{
models: ChatModelsResponse | null;
selectedModel: ChatHubConversationModel | null;
includeCustomAgents?: boolean;
credentials: CredentialsMap;
@ -41,6 +37,7 @@ function handleSelectCredentials(provider: ChatHubProvider, id: string) {
emit('selectCredential', provider, id);
}
const chatStore = useChatStore();
const i18n = useI18n();
const dropdownRef = useTemplateRef('dropdownRef');
const credentialSelectorProvider = ref<Exclude<ChatHubProvider, 'n8n' | 'custom-agent'> | null>(
@ -56,10 +53,8 @@ const credentialsName = computed(() =>
: undefined,
);
const isCustomAgent = computed(() => props.selectedModel?.provider === 'custom-agent');
const menu = computed(() => {
const agents = props.models?.['custom-agent'].models;
const agents = chatStore.models?.['custom-agent'].models;
const agentOptions = (agents ?? [])
.filter((model) => 'agentId' in model)
.map<ComponentProps<typeof N8nNavigationDropdown>['menu'][number]>((agent) => ({
@ -92,8 +87,8 @@ const menu = computed(() => {
provider !== 'custom-agent' && (!props.includeCustomAgents ? provider !== 'n8n' : true),
) // hide n8n agent for now
.map((provider) => {
const models = props.models?.[provider].models ?? [];
const error = props.models?.[provider].error;
const models = chatStore.models?.[provider].models ?? [];
const error = chatStore.models?.[provider].error;
const modelOptions =
models.length > 0
@ -163,7 +158,7 @@ function onSelect(id: string) {
if (value === 'new') {
emit('createAgent');
} else {
const agents = props.models?.['custom-agent'].models;
const agents = chatStore.models?.['custom-agent'].models;
const selected = agents?.find((agent) => 'agentId' in agent && agent.agentId === value);
if (selected) {
@ -188,7 +183,7 @@ function onSelect(id: string) {
const model = parsedProvider === 'n8n' ? null : identifier;
const workflowId = parsedProvider === 'n8n' ? identifier : null;
const selected = props.models?.[parsedProvider].models
const selected = chatStore.models?.[parsedProvider].models
.filter((m) => m.provider !== 'custom-agent')
.find((m) => (m.provider === 'n8n' ? m.workflowId === workflowId : m.model === model));
@ -208,6 +203,16 @@ onClickOutside(
() => dropdownRef.value?.close(),
);
// Reload models when credentials are updated
// TODO: fix duplicate requests
watch(
() => props.credentials,
(credentials) => {
void chatStore.fetchChatModels(credentials);
},
{ immediate: true },
);
defineExpose({
open: () => dropdownRef.value?.open(),
});
@ -240,18 +245,10 @@ defineExpose({
@create-new="handleCreateNewCredential"
/>
<N8nAvatar
v-if="isCustomAgent"
:first-name="selectedModel?.name"
size="xsmall"
:class="$style.icon"
/>
<CredentialIcon
v-else-if="selectedModel && selectedModel.provider in PROVIDER_CREDENTIAL_TYPE_MAP"
:credential-type-name="
PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.provider as ChatHubLLMProvider]
"
:size="credentialsName ? 20 : 16"
<ChatAgentAvatar
v-if="selectedModel"
:model="selectedModel"
:size="credentialsName ? 'md' : 'sm'"
:class="$style.icon"
/>
<div :class="$style.selected">

View File

@ -0,0 +1,76 @@
import { LOCAL_STORAGE_CHAT_HUB_CREDENTIALS } from '@/constants';
import { credentialsMapSchema, type CredentialsMap } from '@/features/ai/chatHub/chat.types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import {
chatHubProviderSchema,
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubProvider,
} from '@n8n/api-types';
import { useLocalStorage } from '@vueuse/core';
import { computed, onMounted } from 'vue';
/**
* Composable for managing chat credentials including auto-selection and user selection.
*/
export function useChatCredentials(userId: string) {
const credentialsStore = useCredentialsStore();
const selectedCredentials = useLocalStorage<CredentialsMap>(
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(userId),
{},
{
writeDefaults: false,
shallow: true,
serializer: {
read: (value) => {
try {
return credentialsMapSchema.parse(JSON.parse(value));
} catch (error) {
return {};
}
},
write: (value) => JSON.stringify(value),
},
},
);
const autoSelectCredentials = computed<CredentialsMap>(() =>
Object.fromEntries(
chatHubProviderSchema.options.map((provider) => {
if (provider === 'n8n' || provider === 'custom-agent') {
return [provider, null];
}
const credentialType = PROVIDER_CREDENTIAL_TYPE_MAP[provider];
if (!credentialType) {
return [provider, null];
}
const lastCreatedCredential =
credentialsStore
.getCredentialsByType(credentialType)
.toSorted((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0]?.id ?? null;
return [provider, lastCreatedCredential];
}),
),
);
const credentialsByProvider = computed<CredentialsMap>(() => ({
...autoSelectCredentials.value,
...selectedCredentials.value,
}));
function selectCredential(provider: ChatHubProvider, id: string) {
selectedCredentials.value = { ...selectedCredentials.value, [provider]: id };
}
onMounted(async () => {
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
});
return { credentialsByProvider, selectCredential };
}

View File

@ -3,6 +3,7 @@ import type { ChatHubProvider } from '@n8n/api-types';
// Route and view identifiers
export const CHAT_VIEW = 'chat';
export const CHAT_CONVERSATION_VIEW = 'chat-conversation';
export const CHAT_AGENTS_VIEW = 'chat-agents';
export const CHAT_STORE = 'chatStore';

View File

@ -1,8 +1,9 @@
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
import { CHAT_VIEW, CHAT_CONVERSATION_VIEW } from './constants';
import { CHAT_VIEW, CHAT_CONVERSATION_VIEW, CHAT_AGENTS_VIEW } from './constants';
const ChatSidebar = async () => await import('@/features/ai/chatHub/components/ChatSidebar.vue');
const ChatView = async () => await import('@/features/ai/chatHub/ChatView.vue');
const ChatAgentsView = async () => await import('@/features/ai/chatHub/ChatAgentsView.vue');
export const ChatModule: FrontendModuleDescription = {
id: 'chat-hub',
@ -33,6 +34,17 @@ export const ChatModule: FrontendModuleDescription = {
middleware: ['authenticated', 'custom'],
},
},
{
name: CHAT_AGENTS_VIEW,
path: '/home/chat/agents',
components: {
default: ChatAgentsView,
sidebar: ChatSidebar,
},
meta: {
middleware: ['authenticated', 'custom'],
},
},
],
projectTabs: {
overview: [],