mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(editor): Chat UI feedback 3 (no-changelog) (#22274)
This commit is contained in:
@@ -303,6 +303,7 @@
|
||||
"chatHub.agent.newAgent": "New Agent",
|
||||
"chatHub.agent.configureCredentials": "Configure credentials",
|
||||
"chatHub.agent.addModel": "Add model",
|
||||
"chatHub.agent.credentialsMissing": "Credentials missing",
|
||||
"chatHub.agent.card.menu.edit": "Edit",
|
||||
"chatHub.agent.card.menu.delete": "Delete",
|
||||
"chatHub.agent.card.noDescription": "No description",
|
||||
@@ -388,12 +389,12 @@
|
||||
"chatHub.tools.editor.cancel": "Cancel",
|
||||
"chatHub.tools.selector.label.count": "{count} Tool | {count} Tools",
|
||||
"chatHub.tools.selector.label.default": "Tools",
|
||||
"chatHub.credentials.selector.placeholder": "Select credential...",
|
||||
"chatHub.credentials.selector.title": "Select {provider} credential",
|
||||
"chatHub.credentials.selector.chooseOrCreate": "Choose or create a credential for {provider}",
|
||||
"chatHub.credentials.selector.createNew": "Create new",
|
||||
"chatHub.credentials.selector.confirm": "Select",
|
||||
"chatHub.credentials.selector.cancel": "Cancel",
|
||||
"chatHub.credentials.selector.deleteButton": "Delete credential",
|
||||
"chatHub.message.actions.readAloud": "Read aloud",
|
||||
"chatHub.message.actions.stopReading": "Stop reading",
|
||||
"chatHub.message.actions.edit": "Edit",
|
||||
|
||||
@@ -58,7 +58,6 @@ const sortOptions = computed(() => [
|
||||
]);
|
||||
|
||||
function handleCreateAgent() {
|
||||
chatStore.currentEditingAgent = null;
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
@@ -81,18 +80,13 @@ async function handleEditAgent(model: ChatHubConversationModel) {
|
||||
}
|
||||
|
||||
if (model.provider === 'custom-agent') {
|
||||
try {
|
||||
await chatStore.fetchCustomAgent(model.agentId);
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
agentId: model.agentId,
|
||||
credentials: credentialsByProvider,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('chatHub.agents.loadError'));
|
||||
}
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
agentId: model.agentId,
|
||||
credentials: credentialsByProvider,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -423,8 +423,8 @@ function handleSwitchAlternative(messageId: string) {
|
||||
chatStore.switchAlternative(sessionId.value, messageId);
|
||||
}
|
||||
|
||||
function handleConfigureCredentials(_provider: ChatHubLLMProvider) {
|
||||
// todo call model selector to open model
|
||||
function handleConfigureCredentials(provider: ChatHubLLMProvider) {
|
||||
headerRef.value?.openCredentialSelector(provider);
|
||||
}
|
||||
|
||||
function handleConfigureModel() {
|
||||
@@ -444,25 +444,18 @@ async function handleUpdateTools(newTools: INode[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditAgent(agentId: string) {
|
||||
try {
|
||||
await chatStore.fetchCustomAgent(agentId);
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
agentId,
|
||||
credentials: credentialsByProvider,
|
||||
onCreateCustomAgent: handleSelectModel,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Failed to load agent');
|
||||
}
|
||||
function handleEditAgent(agentId: string) {
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
agentId,
|
||||
credentials: credentialsByProvider,
|
||||
onCreateCustomAgent: handleSelectModel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openNewAgentCreator() {
|
||||
chatStore.currentEditingAgent = null;
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EDITOR_MODAL_KEY,
|
||||
data: {
|
||||
|
||||
@@ -47,7 +47,12 @@ import type {
|
||||
ChatStreamingState,
|
||||
} from './chat.types';
|
||||
import { retry } from '@n8n/utils/retry';
|
||||
import { buildUiMessages, isLlmProviderModel, isMatchedAgent } from './chat.utils';
|
||||
import {
|
||||
buildUiMessages,
|
||||
createSessionFromStreamingState,
|
||||
isLlmProviderModel,
|
||||
isMatchedAgent,
|
||||
} from './chat.utils';
|
||||
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
@@ -64,7 +69,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
const sessions = ref<ChatHubConversationsResponse>();
|
||||
const sessionsLoadingMore = ref(false);
|
||||
|
||||
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
|
||||
const streaming = ref<ChatStreamingState>();
|
||||
const settingsLoading = ref(false);
|
||||
const settings = ref<Record<ChatHubLLMProvider, ChatProviderSettingsDto> | null>(null);
|
||||
@@ -353,21 +357,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
...sessions.value,
|
||||
data: [
|
||||
...(sessions.value?.data ?? []),
|
||||
{
|
||||
id: streaming.value.sessionId,
|
||||
title: 'New Chat',
|
||||
ownerId: '',
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
credentialId: null,
|
||||
agentName: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tools: [],
|
||||
...flattenModel(streaming.value.model),
|
||||
},
|
||||
],
|
||||
data: [...(sessions.value?.data ?? []), createSessionFromStreamingState(streaming.value)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,6 +516,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
sessionId,
|
||||
model,
|
||||
retryOfMessageId: null,
|
||||
tools,
|
||||
};
|
||||
|
||||
sendMessageApi(
|
||||
@@ -596,6 +587,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
sessionId,
|
||||
model,
|
||||
retryOfMessageId: null,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
editMessageApi(
|
||||
@@ -632,6 +624,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
sessionId,
|
||||
model,
|
||||
retryOfMessageId: retryId,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
regenerateMessageApi(
|
||||
@@ -721,9 +714,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
}
|
||||
|
||||
async function fetchCustomAgent(agentId: string): Promise<ChatHubAgentDto> {
|
||||
const agent = await fetchAgentApi(rootStore.restApiContext, agentId);
|
||||
currentEditingAgent.value = agent;
|
||||
return agent;
|
||||
return await fetchAgentApi(rootStore.restApiContext, agentId);
|
||||
}
|
||||
|
||||
function getCustomAgent(agentId: string) {
|
||||
@@ -860,7 +851,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
*/
|
||||
agents: computed(() => agents.value ?? emptyChatModelsResponse),
|
||||
agentsReady: computed(() => agents.value !== undefined),
|
||||
currentEditingAgent,
|
||||
getAgent,
|
||||
fetchAgents,
|
||||
getCustomAgent,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type EnrichedStructuredChunk,
|
||||
type ChatHubProvider,
|
||||
} from '@n8n/api-types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface UserMessage {
|
||||
@@ -78,6 +79,7 @@ export interface ChatStreamingState extends Partial<EnrichedStructuredChunk['met
|
||||
sessionId: ChatSessionId;
|
||||
model: ChatHubConversationModel;
|
||||
retryOfMessageId: ChatMessageId | null;
|
||||
tools: INode[];
|
||||
}
|
||||
|
||||
export interface FlattenedModel {
|
||||
|
||||
@@ -332,3 +332,18 @@ export function isLlmProviderModel(
|
||||
): model is ChatHubConversationModel & { provider: ChatHubLLMProvider } {
|
||||
return isLlmProvider(model?.provider);
|
||||
}
|
||||
|
||||
export function createSessionFromStreamingState(streaming: ChatStreamingState): ChatHubSessionDto {
|
||||
return {
|
||||
id: streaming.sessionId,
|
||||
title: 'New Chat',
|
||||
ownerId: '',
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
credentialId: null,
|
||||
agentName: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tools: streaming.tools,
|
||||
...flattenModel(streaming.model),
|
||||
};
|
||||
}
|
||||
|
||||
+76
-58
@@ -4,16 +4,17 @@ import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
|
||||
import type { ChatHubProvider, ChatModelDto } from '@n8n/api-types';
|
||||
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel } from '@n8n/design-system';
|
||||
import type { ChatHubBaseLLMModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types';
|
||||
import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { assert } from '@n8n/utils/assert';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { CredentialsMap } from '../chat.types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import ToolsSelector from './ToolsSelector.vue';
|
||||
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
@@ -30,18 +31,26 @@ const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const modalBus = ref(createEventBus());
|
||||
const customAgent = useCustomAgent(props.data.agentId);
|
||||
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const systemPrompt = ref('');
|
||||
const selectedModel = ref<ChatModelDto | null>(null);
|
||||
const selectedModel = ref<ChatHubBaseLLMModel | null>(null);
|
||||
const isSaving = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const tools = ref<INode[]>([]);
|
||||
|
||||
const agentSelectedCredentials = ref<CredentialsMap>({});
|
||||
const credentialIdForSelectedModelProvider = computed(
|
||||
() => selectedModel.value && agentMergedCredentials.value[selectedModel.value.provider],
|
||||
);
|
||||
const selectedAgent = computed(
|
||||
() => selectedModel.value && chatStore.getAgent(selectedModel.value),
|
||||
);
|
||||
|
||||
const isEditMode = computed(() => !!props.data.agentId);
|
||||
const isLoadingAgent = computed(() => isEditMode.value && !customAgent.value);
|
||||
const title = computed(() =>
|
||||
isEditMode.value
|
||||
? i18n.baseText('chatHub.agent.editor.title.edit')
|
||||
@@ -57,7 +66,8 @@ const isValid = computed(() => {
|
||||
return (
|
||||
name.value.trim().length > 0 &&
|
||||
systemPrompt.value.trim().length > 0 &&
|
||||
selectedModel.value !== null
|
||||
selectedModel.value !== null &&
|
||||
!!credentialIdForSelectedModelProvider.value
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,37 +78,34 @@ const agentMergedCredentials = computed((): CredentialsMap => {
|
||||
};
|
||||
});
|
||||
|
||||
function loadAgent() {
|
||||
const customAgent = chatStore.currentEditingAgent;
|
||||
watch(
|
||||
customAgent,
|
||||
(agent) => {
|
||||
if (!agent) return;
|
||||
|
||||
if (!customAgent) return;
|
||||
name.value = agent.name;
|
||||
description.value = agent.description ?? '';
|
||||
systemPrompt.value = agent.systemPrompt;
|
||||
selectedModel.value = { provider: agent.provider, model: agent.model };
|
||||
tools.value = agent.tools || [];
|
||||
|
||||
name.value = customAgent.name;
|
||||
description.value = customAgent.description ?? '';
|
||||
systemPrompt.value = customAgent.systemPrompt;
|
||||
selectedModel.value = chatStore.getAgent(customAgent);
|
||||
tools.value = customAgent.tools || [];
|
||||
if (agent.credentialId) {
|
||||
agentSelectedCredentials.value[agent.provider] = agent.credentialId;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
if (customAgent.credentialId) {
|
||||
agentSelectedCredentials.value[customAgent.provider] = customAgent.credentialId;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.data.agentId) {
|
||||
loadAgent();
|
||||
}
|
||||
});
|
||||
|
||||
function onCredentialSelected(provider: ChatHubProvider, credentialId: string) {
|
||||
function onCredentialSelected(provider: ChatHubProvider, credentialId: string | null) {
|
||||
agentSelectedCredentials.value = {
|
||||
...agentSelectedCredentials.value,
|
||||
[provider]: credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
function onModelChange(model: ChatModelDto) {
|
||||
selectedModel.value = model;
|
||||
function onModelChange(agent: ChatModelDto) {
|
||||
assert(isLlmProviderModel(agent.model));
|
||||
selectedModel.value = agent.model;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
@@ -107,21 +114,14 @@ async function onSave() {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
assert(selectedModel.value);
|
||||
|
||||
const model = 'model' in selectedModel.value ? selectedModel.value.model : undefined;
|
||||
|
||||
assert(isLlmProviderModel(model));
|
||||
|
||||
const credentialId = agentMergedCredentials.value[model.provider];
|
||||
|
||||
assert(credentialId);
|
||||
assert(credentialIdForSelectedModelProvider.value);
|
||||
|
||||
const payload = {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
systemPrompt: systemPrompt.value.trim(),
|
||||
...model,
|
||||
credentialId,
|
||||
...selectedModel.value,
|
||||
credentialId: credentialIdForSelectedModelProvider.value,
|
||||
tools: tools.value,
|
||||
};
|
||||
|
||||
@@ -197,7 +197,17 @@ function onSelectTools(newTools: INode[]) {
|
||||
min-height="400px"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
|
||||
<div :class="$style.header">
|
||||
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
|
||||
<N8nButton
|
||||
v-if="isEditMode"
|
||||
type="secondary"
|
||||
icon="trash-2"
|
||||
:disabled="isDeleting"
|
||||
:loading="isDeleting"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
@@ -212,6 +222,7 @@ function onSelectTools(newTools: INode[]) {
|
||||
:placeholder="i18n.baseText('chatHub.agent.editor.name.placeholder')"
|
||||
:maxlength="128"
|
||||
:class="$style.input"
|
||||
:disabled="isLoadingAgent"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
@@ -227,6 +238,7 @@ function onSelectTools(newTools: INode[]) {
|
||||
:maxlength="512"
|
||||
:rows="3"
|
||||
:class="$style.input"
|
||||
:disabled="isLoadingAgent"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
@@ -242,6 +254,7 @@ function onSelectTools(newTools: INode[]) {
|
||||
:placeholder="i18n.baseText('chatHub.agent.editor.systemPrompt.placeholder')"
|
||||
:rows="6"
|
||||
:class="$style.input"
|
||||
:disabled="isLoadingAgent"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
@@ -253,9 +266,10 @@ function onSelectTools(newTools: INode[]) {
|
||||
:required="true"
|
||||
>
|
||||
<ModelSelector
|
||||
:selected-agent="selectedModel"
|
||||
:selected-agent="selectedAgent"
|
||||
:include-custom-agents="false"
|
||||
:credentials="agentMergedCredentials"
|
||||
:disabled="isLoadingAgent"
|
||||
@change="onModelChange"
|
||||
@select-credential="onCredentialSelected"
|
||||
/>
|
||||
@@ -268,33 +282,42 @@ function onSelectTools(newTools: INode[]) {
|
||||
:required="false"
|
||||
>
|
||||
<div>
|
||||
<ToolsSelector :disabled="false" :selected="tools" @select="onSelectTools" />
|
||||
<ToolsSelector :disabled="isLoadingAgent" :selected="tools" @select="onSelectTools" />
|
||||
</div>
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
<N8nSpinner v-if="isLoadingAgent" :class="$style.spinner" size="xlarge" />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<div :class="$style.footerRight">
|
||||
<N8nButton
|
||||
v-if="isEditMode"
|
||||
type="secondary"
|
||||
icon="trash-2"
|
||||
:disabled="isDeleting"
|
||||
:loading="isDeleting"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<N8nButton type="primary" :disabled="!isValid || isSaving" @click="onSave">
|
||||
{{ saveButtonLabel }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nButton type="secondary" @click="modalBus.emit('close')">{{
|
||||
i18n.baseText('chatHub.tools.editor.cancel')
|
||||
}}</N8nButton>
|
||||
<N8nButton type="primary" :disabled="!isValid || isSaving" @click="onSave">
|
||||
{{ saveButtonLabel }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.spinner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--s);
|
||||
padding-right: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -315,11 +338,6 @@ function onSelectTools(newTools: INode[]) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
+9
-2
@@ -4,7 +4,12 @@ import ChatSidebarOpener from '@/features/ai/chatHub/components/ChatSidebarOpene
|
||||
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
|
||||
import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState';
|
||||
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import type { ChatHubProvider, ChatModelDto, ChatSessionId } from '@n8n/api-types';
|
||||
import type {
|
||||
ChatHubLLMProvider,
|
||||
ChatHubProvider,
|
||||
ChatModelDto,
|
||||
ChatSessionId,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nButton, N8nIconButton } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useTemplateRef } from 'vue';
|
||||
@@ -21,7 +26,7 @@ const emit = defineEmits<{
|
||||
renameConversation: [id: ChatSessionId, title: string];
|
||||
editCustomAgent: [agentId: string];
|
||||
createCustomAgent: [];
|
||||
selectCredential: [provider: ChatHubProvider, credentialId: string];
|
||||
selectCredential: [provider: ChatHubProvider, credentialId: string | null];
|
||||
openWorkflow: [workflowId: string];
|
||||
}>();
|
||||
|
||||
@@ -42,6 +47,8 @@ function onNewChat() {
|
||||
|
||||
defineExpose({
|
||||
openModelSelector: () => modelSelectorRef.value?.open(),
|
||||
openCredentialSelector: (provider: ChatHubLLMProvider) =>
|
||||
modelSelectorRef.value?.openCredentialSelector(provider),
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ defineExpose({
|
||||
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
|
||||
&:focus-within,
|
||||
&:hover {
|
||||
&:hover:has(textarea:not(:disabled)) {
|
||||
border-color: var(--color--secondary);
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -17,6 +17,7 @@ import { useIntersectionObserver } from '@vueuse/core';
|
||||
import ChatSessionMenuItem from './ChatSessionMenuItem.vue';
|
||||
import SkeletonMenuItem from './SkeletonMenuItem.vue';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
defineProps<{ isMobileDevice: boolean }>();
|
||||
@@ -28,7 +29,11 @@ const toast = useToast();
|
||||
const message = useMessage();
|
||||
const sidebar = useChatHubSidebarState();
|
||||
const settingsStore = useSettingsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const telemetry = useTelemetry();
|
||||
const readyToShowSessions = computed(
|
||||
() => chatStore.sessionsReady && credentialsStore.allCredentialTypes.length > 0,
|
||||
);
|
||||
const i18n = useI18n();
|
||||
|
||||
const renamingSessionId = ref<string>();
|
||||
@@ -148,13 +153,11 @@ onMounted(() => {
|
||||
</div>
|
||||
<N8nScrollArea as-child type="scroll">
|
||||
<div :class="$style.items">
|
||||
<div
|
||||
v-if="groupedConversations.length === 0 && !chatStore.sessionsReady"
|
||||
:class="$style.group"
|
||||
>
|
||||
<div v-if="!readyToShowSessions" :class="$style.group">
|
||||
<SkeletonMenuItem v-for="i in 10" :key="`loading-${i}`" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(group, index) in groupedConversations"
|
||||
:key="group.group"
|
||||
:class="$style.group"
|
||||
|
||||
+97
-39
@@ -1,38 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { N8nButton, N8nHeading, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
|
||||
import { N8nButton, N8nHeading, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { type ChatHubLLMProvider, PROVIDER_CREDENTIAL_TYPE_MAP } from '@n8n/api-types';
|
||||
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
|
||||
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
|
||||
import CredentialPicker from '@/features/credentials/components/CredentialPicker/CredentialPicker.vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
provider: ChatHubLLMProvider;
|
||||
initialValue: string | null;
|
||||
onSelect: (provider: ChatHubLLMProvider, credentialId: string) => void;
|
||||
onSelect: (provider: ChatHubLLMProvider, credentialId: string | null) => void;
|
||||
onCreateNew: (provider: ChatHubLLMProvider) => void;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const modalBus = ref(createEventBus());
|
||||
const selectedCredentialId = ref<string | null>(props.data.initialValue);
|
||||
|
||||
const availableCredentials = computed<ICredentialsResponse[]>(() => {
|
||||
return credentialsStore.getCredentialsByType(PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider]);
|
||||
const credentialType = computed(() => PROVIDER_CREDENTIAL_TYPE_MAP[props.data.provider]);
|
||||
|
||||
const selectedCredential = computed(() => {
|
||||
if (!selectedCredentialId.value) {
|
||||
return null;
|
||||
}
|
||||
return credentialsStore.getCredentialById(selectedCredentialId.value);
|
||||
});
|
||||
|
||||
function onCredentialSelect(credentialId: string) {
|
||||
selectedCredentialId.value = credentialId;
|
||||
}
|
||||
|
||||
function onCredentialDeselect() {
|
||||
selectedCredentialId.value = null;
|
||||
}
|
||||
|
||||
async function onDeleteCredential() {
|
||||
if (!selectedCredential.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialIdToDelete = selectedCredential.value.id;
|
||||
|
||||
const deleteConfirmed = await message.confirm(
|
||||
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', {
|
||||
interpolate: { savedCredentialName: selectedCredential.value.name },
|
||||
}),
|
||||
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText(
|
||||
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await credentialsStore.deleteCredential({ id: credentialIdToDelete });
|
||||
selectedCredentialId.value = null;
|
||||
|
||||
if (credentialIdToDelete === props.data.initialValue) {
|
||||
props.data.onSelect(props.data.provider, null);
|
||||
}
|
||||
|
||||
modalBus.value.emit('close');
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error,
|
||||
i18n.baseText('credentialEdit.credentialEdit.showError.deleteCredential.title'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (selectedCredentialId.value) {
|
||||
props.data.onSelect(props.data.provider, selectedCredentialId.value);
|
||||
@@ -40,11 +94,6 @@ function onConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateNew() {
|
||||
props.data.onCreateNew(props.data.provider);
|
||||
modalBus.value.emit('close');
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
modalBus.value.emit('close');
|
||||
}
|
||||
@@ -88,34 +137,36 @@ function onCancel() {
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="selectedCredentialId"
|
||||
size="large"
|
||||
:placeholder="i18n.baseText('chatHub.credentials.selector.placeholder')"
|
||||
@update:model-value="onCredentialSelect"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="credential in availableCredentials"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="credential.name"
|
||||
<div :class="$style.credentialContainer">
|
||||
<CredentialPicker
|
||||
:class="$style.credentialPicker"
|
||||
:app-name="providerDisplayNames[data.provider]"
|
||||
:credential-type="credentialType"
|
||||
:selected-credential-id="selectedCredentialId"
|
||||
:hide-create-new="true"
|
||||
@credential-selected="onCredentialSelect"
|
||||
@credential-deselected="onCredentialDeselect"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nIconButton
|
||||
v-if="selectedCredentialId"
|
||||
native-type="button"
|
||||
:title="i18n.baseText('chatHub.credentials.selector.deleteButton')"
|
||||
icon="trash-2"
|
||||
icon-size="large"
|
||||
type="secondary"
|
||||
@click="onDeleteCredential"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton type="secondary" @click="onCreateNew">
|
||||
{{ i18n.baseText('chatHub.credentials.selector.createNew') }}
|
||||
<N8nButton type="tertiary" @click="onCancel">
|
||||
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
|
||||
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerRight">
|
||||
<N8nButton type="tertiary" @click="onCancel">
|
||||
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
|
||||
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
@@ -131,14 +182,10 @@ function onCancel() {
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -151,4 +198,15 @@ function onCancel() {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.credentialContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.credentialPicker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,10 +55,10 @@ const {
|
||||
const emit = defineEmits<{
|
||||
change: [ChatModelDto];
|
||||
createCustomAgent: [];
|
||||
selectCredential: [provider: ChatHubProvider, credentialId: string];
|
||||
selectCredential: [provider: ChatHubProvider, credentialId: string | null];
|
||||
}>();
|
||||
|
||||
function handleSelectCredentials(provider: ChatHubProvider, id: string) {
|
||||
function handleSelectCredentials(provider: ChatHubProvider, id: string | null) {
|
||||
emit('selectCredential', provider, id);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,12 @@ const credentialsName = computed(() =>
|
||||
: undefined,
|
||||
);
|
||||
const isCredentialsRequired = computed(() => isLlmProviderModel(selectedAgent?.model));
|
||||
const isCredentialsMissing = computed(
|
||||
() =>
|
||||
isCredentialsRequired.value &&
|
||||
selectedAgent?.model.provider &&
|
||||
!credentials?.[selectedAgent?.model.provider],
|
||||
);
|
||||
|
||||
const menu = computed(() => {
|
||||
const menuItems: (typeof N8nNavigationDropdown)['menu'] = [];
|
||||
@@ -306,11 +312,13 @@ watch(
|
||||
|
||||
defineExpose({
|
||||
open: () => dropdownRef.value?.open(),
|
||||
openCredentialSelector: (provider: ChatHubLLMProvider) =>
|
||||
openCredentialsSelectorOrCreate(provider),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" @select="onSelect">
|
||||
<N8nNavigationDropdown ref="dropdownRef" :menu="menu" teleport @select="onSelect">
|
||||
<template #item-icon="{ item }">
|
||||
<CredentialIcon
|
||||
v-if="item.id in PROVIDER_CREDENTIAL_TYPE_MAP"
|
||||
@@ -340,6 +348,14 @@ defineExpose({
|
||||
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
|
||||
{{ credentialsName }}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
|
||||
<N8nIcon
|
||||
icon="node-validation-error"
|
||||
size="xsmall"
|
||||
:class="$style.credentialsMissingIcon"
|
||||
/>
|
||||
{{ i18n.baseText('chatHub.agent.credentialsMissing') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nIcon icon="chevron-down" size="medium" />
|
||||
</N8nButton>
|
||||
@@ -356,6 +372,11 @@ defineExpose({
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.credentialsMissingIcon {
|
||||
display: inline-block;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -85,7 +85,7 @@ const onClick = () => {
|
||||
gap: var(--spacing--2xs);
|
||||
|
||||
&.transparentBg {
|
||||
background-color: transparent;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -81,14 +81,20 @@ export function useChatCredentials(userId: string) {
|
||||
|
||||
const credentialsByProvider = computed<CredentialsMap | null>(() =>
|
||||
isCredentialsReady.value
|
||||
? {
|
||||
...autoSelectCredentials.value,
|
||||
...selectedCredentials.value,
|
||||
}
|
||||
? chatHubProviderSchema.options.reduce<CredentialsMap>((acc, provider) => {
|
||||
const cred = selectedCredentials.value[provider] ?? null;
|
||||
|
||||
acc[provider] =
|
||||
cred && credentialsStore.allCredentials.some((c) => c.id === cred)
|
||||
? cred
|
||||
: autoSelectCredentials.value[provider];
|
||||
|
||||
return acc;
|
||||
}, {})
|
||||
: null,
|
||||
);
|
||||
|
||||
function selectCredential(provider: ChatHubProvider, id: string) {
|
||||
function selectCredential(provider: ChatHubProvider, id: string | null) {
|
||||
selectedCredentials.value = { ...selectedCredentials.value, [provider]: id };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ChatHubAgentDto } from '@n8n/api-types';
|
||||
import { ref, toValue, watch, type MaybeRef } from 'vue';
|
||||
import { useChatStore } from '../chat.store';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
|
||||
export function useCustomAgent(agentId?: MaybeRef<string | undefined>) {
|
||||
const store = useChatStore();
|
||||
const agent = ref<ChatHubAgentDto>();
|
||||
const toast = useToast();
|
||||
|
||||
watch(
|
||||
() => toValue(agentId),
|
||||
async (id) => {
|
||||
if (!id) {
|
||||
agent.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
agent.value = await store.fetchCustomAgent(id);
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Failed to load agent');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
Reference in New Issue
Block a user