mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat(editor): Chat agent directory (no-changelog) (#21236)
This commit is contained in:
parent
9767afde19
commit
3b36f7341d
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const sizesInPixels: Record<IconSize, number> = {
|
|||
medium: 14,
|
||||
large: 16,
|
||||
xlarge: 20,
|
||||
xxlarge: 40,
|
||||
};
|
||||
|
||||
const size = computed((): { height: string; width: string } => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -62,3 +62,9 @@ export interface GroupedConversations {
|
|||
group: string;
|
||||
sessions: ChatHubSessionDto[];
|
||||
}
|
||||
|
||||
export interface ChatAgentFilter {
|
||||
sortBy: 'updatedAt' | 'createdAt';
|
||||
provider: 'custom-agent' | 'n8n' | '';
|
||||
search: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user