mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
feat(editor): Chat: show conversation history in drawer on mobile device (no-changelog) (#20661)
This commit is contained in:
parent
dbe9f9f923
commit
fd3caae509
|
|
@ -239,7 +239,7 @@ defineExpose({
|
|||
.subMenuTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.submenu__icon {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref, useTemplateRef } from 'vue';
|
||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
N8nLogo,
|
||||
N8nPopoverReka,
|
||||
N8nScrollArea,
|
||||
N8nAvatar,
|
||||
N8nText,
|
||||
N8nIcon,
|
||||
N8nButton,
|
||||
|
|
@ -43,7 +41,6 @@ import { useSourceControlStore } from '@/features/sourceControl.ee/sourceControl
|
|||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useUserHelpers } from '@/composables/useUserHelpers';
|
||||
import { useBugReporting } from '@/composables/useBugReporting';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||
|
|
@ -59,6 +56,7 @@ import { useKeybindings } from '@/composables/useKeybindings';
|
|||
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
|
||||
import ProjectNavigation from '@/features/projects/components/ProjectNavigation.vue';
|
||||
import MainSidebarSourceControl from './MainSidebarSourceControl.vue';
|
||||
import MainSidebarUserArea from '@/components/MainSidebarUserArea.vue';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
|
||||
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
||||
|
|
@ -77,8 +75,6 @@ const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
|
|||
const { callDebounced } = useDebounce();
|
||||
const externalHooks = useExternalHooks();
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
const { getReportingURL } = useBugReporting();
|
||||
|
|
@ -88,26 +84,13 @@ const posthogStore = usePostHog();
|
|||
useKeybindings({
|
||||
ctrl_alt_o: () => handleSelect('about'),
|
||||
});
|
||||
useUserHelpers(router, route);
|
||||
|
||||
// Template refs
|
||||
const user = ref<Element | null>(null);
|
||||
const user = useTemplateRef('user');
|
||||
|
||||
// Component data
|
||||
const basePath = ref('');
|
||||
const fullyExpanded = ref(false);
|
||||
const userMenuItems = ref<IMenuItem[]>([
|
||||
{
|
||||
id: 'settings',
|
||||
icon: 'settings',
|
||||
label: i18n.baseText('settings'),
|
||||
},
|
||||
{
|
||||
id: 'logout',
|
||||
icon: 'door-open',
|
||||
label: i18n.baseText('auth.signout'),
|
||||
},
|
||||
]);
|
||||
|
||||
const showWhatsNewNotification = computed(
|
||||
() =>
|
||||
|
|
@ -342,9 +325,9 @@ const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
|
|||
onMounted(async () => {
|
||||
window.addEventListener('resize', onResize);
|
||||
basePath.value = rootStore.baseUrl;
|
||||
if (user.value) {
|
||||
if (user.value?.$el) {
|
||||
void externalHooks.run('mainSidebar.mounted', {
|
||||
userRef: user.value,
|
||||
userRef: user.value.$el,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -365,23 +348,6 @@ const trackHelpItemClick = (itemType: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
const onUserActionToggle = (action: string) => {
|
||||
switch (action) {
|
||||
case 'logout':
|
||||
onLogout();
|
||||
break;
|
||||
case 'settings':
|
||||
void router.push({ name: VIEWS.SETTINGS });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
void router.push({ name: VIEWS.SIGNOUT });
|
||||
};
|
||||
|
||||
const toggleCollapse = () => {
|
||||
uiStore.toggleSidebarMenuCollapse();
|
||||
// When expanding, delay showing some element to ensure smooth animation
|
||||
|
|
@ -640,54 +606,12 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
</N8nScrollArea>
|
||||
|
||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||
<div v-if="showUserArea">
|
||||
<div ref="user" :class="$style.userArea">
|
||||
<N8nPopoverReka side="right" align="end" :side-offset="16">
|
||||
<template #content>
|
||||
<div :class="$style.popover">
|
||||
<N8nMenuItem
|
||||
v-for="action in userMenuItems"
|
||||
:key="action.id"
|
||||
:item="action"
|
||||
:data-test-id="`user-menu-item-${action.id}`"
|
||||
@click="() => onUserActionToggle(action.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<div :class="$style.userAreaInner">
|
||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
||||
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||
<N8nAvatar
|
||||
:first-name="usersStore.currentUser?.firstName"
|
||||
:last-name="usersStore.currentUser?.lastName"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
['ml-2xs']: true,
|
||||
[$style.userName]: true,
|
||||
[$style.expanded]: fullyExpanded,
|
||||
}"
|
||||
>
|
||||
<N8nText size="small" color="text-dark">{{
|
||||
usersStore.currentUser?.fullName
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<div
|
||||
data-test-id="user-menu"
|
||||
:class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"
|
||||
>
|
||||
<N8nIconButton icon="ellipsis" text square type="tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
</div>
|
||||
</div>
|
||||
<MainSidebarUserArea
|
||||
v-if="showUserArea"
|
||||
ref="user"
|
||||
:fully-expanded="fullyExpanded"
|
||||
:is-collapsed="isCollapsed"
|
||||
/>
|
||||
|
||||
<TemplateTooltip />
|
||||
</div>
|
||||
|
|
@ -773,44 +697,6 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.userArea {
|
||||
display: flex;
|
||||
padding: var(--spacing--xs);
|
||||
align-items: center;
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
|
||||
.userName {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
width: 100px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.expanded {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.userActions {
|
||||
display: none;
|
||||
|
||||
&.expanded {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userAreaInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 470px) {
|
||||
:global(#help) {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
<script setup lang="ts">
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import {
|
||||
type IMenuItem,
|
||||
N8nAvatar,
|
||||
N8nIconButton,
|
||||
N8nMenuItem,
|
||||
N8nPopoverReka,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
defineProps<{ fullyExpanded: boolean; isCollapsed: boolean }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const userMenuItems = ref<IMenuItem[]>([
|
||||
{
|
||||
id: 'settings',
|
||||
icon: 'settings',
|
||||
label: i18n.baseText('settings'),
|
||||
},
|
||||
{
|
||||
id: 'logout',
|
||||
icon: 'door-open',
|
||||
label: i18n.baseText('auth.signout'),
|
||||
},
|
||||
]);
|
||||
|
||||
const onLogout = () => {
|
||||
void router.push({ name: VIEWS.SIGNOUT });
|
||||
};
|
||||
|
||||
const onUserActionToggle = (action: string) => {
|
||||
switch (action) {
|
||||
case 'logout':
|
||||
onLogout();
|
||||
break;
|
||||
case 'settings':
|
||||
void router.push({ name: VIEWS.SETTINGS });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="user" :class="$style.userArea">
|
||||
<N8nPopoverReka side="right" align="end" :side-offset="16">
|
||||
<template #content>
|
||||
<div :class="$style.popover">
|
||||
<N8nMenuItem
|
||||
v-for="action in userMenuItems"
|
||||
:key="action.id"
|
||||
:item="action"
|
||||
:data-test-id="`user-menu-item-${action.id}`"
|
||||
@click="() => onUserActionToggle(action.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<div :class="$style.userAreaInner">
|
||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
||||
<div :class="{ ['clickable']: isCollapsed }">
|
||||
<N8nAvatar
|
||||
:first-name="usersStore.currentUser?.firstName"
|
||||
:last-name="usersStore.currentUser?.lastName"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
['ml-2xs']: true,
|
||||
[$style.userName]: true,
|
||||
[$style.expanded]: fullyExpanded,
|
||||
}"
|
||||
>
|
||||
<N8nText size="small" color="text-dark">
|
||||
{{ usersStore.currentUser?.fullName }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div
|
||||
data-test-id="user-menu"
|
||||
:class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"
|
||||
>
|
||||
<N8nIconButton icon="ellipsis" text square type="tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.userArea {
|
||||
display: flex;
|
||||
padding: var(--spacing--xs);
|
||||
align-items: center;
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
|
||||
.userName {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
width: 100px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.expanded {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.userActions {
|
||||
display: none;
|
||||
|
||||
&.expanded {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userAreaInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popover {
|
||||
padding: var(--spacing--xs);
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { ElDrawer } from 'element-plus';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
|
|
@ -13,12 +14,14 @@ const props = withDefaults(
|
|||
width: string;
|
||||
wrapperClosable?: boolean;
|
||||
closeOnClickModal?: boolean;
|
||||
showClose?: boolean;
|
||||
zIndex?: number;
|
||||
}>(),
|
||||
{
|
||||
modal: true,
|
||||
wrapperClosable: true,
|
||||
closeOnClickModal: false,
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -74,13 +77,14 @@ onBeforeUnmount(() => {
|
|||
<template>
|
||||
<ElDrawer
|
||||
:direction="direction"
|
||||
:model-value="uiStore.modalsById[name].open"
|
||||
:model-value="uiStore.modalsById[name]?.open ?? false"
|
||||
:size="width"
|
||||
:before-close="close"
|
||||
:modal="modal"
|
||||
:wrapper-closable="wrapperClosable"
|
||||
:close-on-click-modal="closeOnClickModal"
|
||||
:z-index="zIndex"
|
||||
:show-close="showClose"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header" />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { N8nIcon, N8nLink, N8nMenuItem, N8nText, type IMenuItem } from '@n8n/design-system';
|
||||
|
|
@ -15,10 +15,9 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
|
||||
const { canUserAccessRouteByName } = useUserHelpers(router, route);
|
||||
const { canUserAccessRouteByName } = useUserHelpers(router);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import type { RouteLocation, RouteLocationNormalizedLoaded, Router } from 'vue-router';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import type { PermissionTypeOptions } from '@/types/rbac';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import type { RouteLocation, Router } from 'vue-router';
|
||||
|
||||
export function useUserHelpers(router: Router, route: RouteLocationNormalizedLoaded) {
|
||||
export function useUserHelpers(router: Router) {
|
||||
const canUserAccessRouteByName = (name: string) => {
|
||||
const resolvedRoute = router.resolve({ name });
|
||||
|
||||
return canUserAccessRoute(resolvedRoute);
|
||||
};
|
||||
|
||||
const canUserAccessCurrentRoute = () => {
|
||||
return canUserAccessRoute(route);
|
||||
};
|
||||
|
||||
const canUserAccessRoute = (route: RouteLocation) => {
|
||||
const middleware = route.meta?.middleware;
|
||||
const middlewareOptions = route.meta?.middlewareOptions;
|
||||
|
|
@ -26,7 +22,5 @@ export function useUserHelpers(router: Router, route: RouteLocationNormalizedLoa
|
|||
|
||||
return {
|
||||
canUserAccessRouteByName,
|
||||
canUserAccessCurrentRoute,
|
||||
canUserAccessRoute,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const WHATS_NEW_MODAL_KEY = 'whatsNew';
|
|||
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
|
||||
export const PRE_BUILT_AGENTS_MODAL_KEY = 'preBuiltAgents';
|
||||
export const VARIABLE_MODAL_KEY = 'variableModal';
|
||||
export const CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY = 'chatHubSideMenuDrawer';
|
||||
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
|
||||
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
|
||||
|
||||
|
|
@ -508,8 +509,10 @@ export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
|
|||
export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS =
|
||||
'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS';
|
||||
export const LOCAL_STORAGE_RUN_DATA_WORKER = 'N8N_RUN_DATA_WORKER';
|
||||
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = 'N8N_CHAT_HUB_SELECTED_MODEL';
|
||||
export const LOCAL_STORAGE_CHAT_HUB_CREDENTIALS = 'N8N_CHAT_HUB_CREDENTIALS';
|
||||
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL = (userId: string) =>
|
||||
`${userId}_N8N_CHAT_HUB_SELECTED_MODEL`;
|
||||
export const LOCAL_STORAGE_CHAT_HUB_CREDENTIALS = (userId: string) =>
|
||||
`${userId}_N8N_CHAT_HUB_CREDENTIALS`;
|
||||
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
|
|
|
|||
|
|
@ -2,23 +2,15 @@
|
|||
import { ref, computed, watch, nextTick, onMounted, useTemplateRef } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
|
||||
import { N8nHeading, N8nIcon, N8nText, N8nScrollArea } from '@n8n/design-system';
|
||||
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
||||
import { N8nIcon, N8nScrollArea, N8nIconButton } from '@n8n/design-system';
|
||||
import ModelSelector from './components/ModelSelector.vue';
|
||||
import CredentialSelectorModal from './components/CredentialSelectorModal.vue';
|
||||
|
||||
import { useChatStore } from './chat.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import {
|
||||
credentialsMapSchema,
|
||||
type ChatMessage,
|
||||
type CredentialsMap,
|
||||
type Suggestion,
|
||||
} from './chat.types';
|
||||
import { credentialsMapSchema, type CredentialsMap, type Suggestion } from './chat.types';
|
||||
import {
|
||||
chatHubConversationModelSchema,
|
||||
type ChatHubProvider,
|
||||
|
|
@ -26,28 +18,35 @@ import {
|
|||
type ChatHubConversationModel,
|
||||
chatHubProviderSchema,
|
||||
} from '@n8n/api-types';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
|
||||
import {
|
||||
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
|
||||
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS,
|
||||
CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW, SUGGESTIONS } from '@/features/chatHub/constants';
|
||||
import {
|
||||
CHAT_CONVERSATION_VIEW,
|
||||
CHAT_VIEW,
|
||||
MOBILE_MEDIA_QUERY,
|
||||
} from '@/features/chatHub/constants';
|
||||
import { findOneFromModelsResponse } from '@/features/chatHub/chat.utils';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import ChatMessage from '@/features/chatHub/components/ChatMessage.vue';
|
||||
import ChatPrompt from '@/features/chatHub/components/ChatPrompt.vue';
|
||||
import ChatTypingIndicator from '@/features/chatHub/components/ChatTypingIndicator.vue';
|
||||
import ChatStarter from '@/features/chatHub/components/ChatStarter.vue';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const usersStore = useUsersStore();
|
||||
const chatStore = useChatStore();
|
||||
const userStore = useUsersStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
|
||||
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
const message = ref('');
|
||||
const sessionId = computed<string>(() =>
|
||||
typeof route.params.id === 'string' ? route.params.id : uuidv4(),
|
||||
);
|
||||
|
|
@ -56,7 +55,7 @@ const messagesRef = ref<HTMLDivElement | null>(null);
|
|||
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
|
||||
const credentialSelectorProvider = ref<ChatHubProvider | null>(null);
|
||||
const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
|
||||
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL,
|
||||
LOCAL_STORAGE_CHAT_HUB_SELECTED_MODEL(usersStore.currentUserId ?? 'anonymous'),
|
||||
null,
|
||||
{
|
||||
writeDefaults: false,
|
||||
|
|
@ -75,7 +74,7 @@ const selectedModel = useLocalStorage<ChatHubConversationModel | null>(
|
|||
);
|
||||
|
||||
const selectedCredentials = useLocalStorage<CredentialsMap>(
|
||||
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS,
|
||||
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(usersStore.currentUserId ?? 'anonymous'),
|
||||
{},
|
||||
{
|
||||
writeDefaults: false,
|
||||
|
|
@ -227,8 +226,8 @@ function onCreateNewCredential(provider: ChatHubProvider) {
|
|||
uiStore.openNewCredential(PROVIDER_CREDENTIAL_TYPE_MAP[provider]);
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (!message.value.trim() || chatStore.isResponding || !selectedModel.value) {
|
||||
function onSubmit(message: string) {
|
||||
if (!message.trim() || chatStore.isResponding || !selectedModel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -238,14 +237,14 @@ function onSubmit() {
|
|||
return;
|
||||
}
|
||||
|
||||
chatStore.askAI(sessionId.value, message.value, selectedModel.value, {
|
||||
chatStore.askAI(sessionId.value, message, selectedModel.value, {
|
||||
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
|
||||
id: credentialsId,
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
message.value = '';
|
||||
inputRef.value?.setText('');
|
||||
|
||||
if (isNewSession.value) {
|
||||
void router.push({ name: CHAT_CONVERSATION_VIEW, params: { id: sessionId.value } });
|
||||
|
|
@ -253,50 +252,37 @@ function onSubmit() {
|
|||
}
|
||||
|
||||
function onSuggestionClick(s: Suggestion) {
|
||||
message.value = `${s.title} ${s.subtitle}`;
|
||||
inputRef.value?.setText(`${s.title} ${s.subtitle}`);
|
||||
}
|
||||
|
||||
function onAttach() {}
|
||||
|
||||
function onMic() {}
|
||||
|
||||
function messageText(msg: ChatMessage) {
|
||||
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
|
||||
}
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return ''; // use external default escaping
|
||||
},
|
||||
};
|
||||
|
||||
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout>
|
||||
<ModelSelector
|
||||
:class="$style.modelSelector"
|
||||
:models="chatStore.models ?? null"
|
||||
:selected-model="selectedModel"
|
||||
:disabled="chatStore.isResponding"
|
||||
:credentials-name="credentialsName"
|
||||
@change="onModelChange"
|
||||
@configure="onConfigure"
|
||||
/>
|
||||
<N8nScrollArea
|
||||
ref="scrollAreaRef"
|
||||
type="hover"
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
:viewport-class="$style.scrollViewport"
|
||||
as-child
|
||||
:class="{ [$style.hasMessages]: hasMessages, [$style.isMobileDevice]: isMobileDevice }"
|
||||
>
|
||||
<div :class="$style.floating">
|
||||
<N8nIconButton
|
||||
v-if="isMobileDevice"
|
||||
type="secondary"
|
||||
icon="menu"
|
||||
@click="uiStore.openModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY)"
|
||||
/>
|
||||
<ModelSelector
|
||||
:models="chatStore.models ?? null"
|
||||
:selected-model="selectedModel"
|
||||
:disabled="chatStore.isResponding"
|
||||
:credentials-name="credentialsName"
|
||||
@change="onModelChange"
|
||||
@configure="onConfigure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CredentialSelectorModal
|
||||
v-if="credentialSelectorProvider"
|
||||
:key="credentialSelectorProvider"
|
||||
|
|
@ -305,180 +291,51 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
|||
@select="onCredentialSelected"
|
||||
@create-new="onCreateNewCredential"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
[$style.content]: true,
|
||||
[$style.centered]: !hasMessages,
|
||||
}"
|
||||
>
|
||||
<section
|
||||
:class="{
|
||||
[$style.section]: true,
|
||||
[$style.fullHeight]: hasMessages,
|
||||
}"
|
||||
>
|
||||
<div v-if="!hasMessages" :class="$style.header">
|
||||
<N8nHeading tag="h2" bold size="xlarge">
|
||||
{{
|
||||
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`
|
||||
}}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasMessages" :class="$style.suggestions">
|
||||
<button
|
||||
v-for="s in SUGGESTIONS"
|
||||
:key="s.title"
|
||||
type="button"
|
||||
:class="$style.card"
|
||||
@click="onSuggestionClick(s)"
|
||||
>
|
||||
<div :class="$style.cardIcon" aria-hidden="true">
|
||||
<N8nText size="xlarge">{{ s.icon }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.cardText">
|
||||
<N8nText bold color="text-dark">{{ s.title }}</N8nText>
|
||||
<N8nText color="text-base">{{ s.subtitle }}</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.scrollable">
|
||||
<ChatStarter
|
||||
v-if="!hasMessages"
|
||||
:class="$style.starter"
|
||||
:is-mobile-device="isMobileDevice"
|
||||
@select="onSuggestionClick"
|
||||
/>
|
||||
|
||||
<!-- Chat thread -->
|
||||
<template v-else>
|
||||
<div :class="$style.threadContainer">
|
||||
<N8nScrollArea
|
||||
ref="scrollAreaRef"
|
||||
type="hover"
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
:class="$style.threadWrap"
|
||||
>
|
||||
<div ref="messagesRef" :class="$style.thread" role="log" aria-live="polite">
|
||||
<div
|
||||
v-for="m in chatMessages"
|
||||
:key="m.id"
|
||||
:class="[
|
||||
$style.message,
|
||||
m.role === 'user' ? $style.user : $style.assistant,
|
||||
m.type === 'error' && $style.error,
|
||||
]"
|
||||
>
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon
|
||||
:icon="m.role === 'user' ? 'user' : 'sparkles'"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
[$style.chatMessage]: true,
|
||||
[$style.chatMessageFromUser]: m.role === 'user',
|
||||
[$style.chatMessageFromAssistant]: m.role === 'assistant',
|
||||
}"
|
||||
>
|
||||
<VueMarkdown
|
||||
:class="$style.chatMessageMarkdown"
|
||||
:source="messageText(m)"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="messagesRef" role="log" aria-live="polite" :class="$style.messageList">
|
||||
<ChatMessage v-for="m in chatMessages" :key="m.id" :message="m" :compact="isMobileDevice" />
|
||||
|
||||
<!-- Typing indicator while streaming -->
|
||||
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon icon="sparkles" width="20" height="20" />
|
||||
</div>
|
||||
<div :class="$style.bubble">
|
||||
<span :class="$style.typing"><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<div v-if="chatStore.isResponding" :class="[$style.message, $style.assistant]">
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon icon="sparkles" width="20" height="20" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Prompt -->
|
||||
<form :class="$style.prompt" @submit.prevent="onSubmit">
|
||||
<div :class="$style.inputWrap">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="message"
|
||||
:class="$style.input"
|
||||
type="text"
|
||||
:placeholder="inputPlaceholder"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
:disabled="chatStore.isResponding"
|
||||
/>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Attach"
|
||||
:disabled="chatStore.isResponding"
|
||||
@click="onAttach"
|
||||
>
|
||||
<N8nIcon icon="paperclip" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Voice"
|
||||
:disabled="chatStore.isResponding"
|
||||
@click="onMic"
|
||||
>
|
||||
<N8nIcon icon="mic" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
:class="$style.sendBtn"
|
||||
type="submit"
|
||||
:disabled="chatStore.isResponding || !message.trim()"
|
||||
>
|
||||
<span v-if="!chatStore.isResponding">Send</span>
|
||||
<span v-else>…</span>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.bubble">
|
||||
<ChatTypingIndicator v-if="chatStore.isResponding" />
|
||||
</div>
|
||||
<N8nText :class="$style.disclaimer" color="text-light" size="small">
|
||||
AI may make mistakes. Check important info.
|
||||
<br />
|
||||
{{ sessionId }}
|
||||
</N8nText>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.promptContainer">
|
||||
<ChatPrompt
|
||||
ref="inputRef"
|
||||
:class="$style.prompt"
|
||||
:placeholder="inputPlaceholder"
|
||||
:disabled="chatStore.isResponding"
|
||||
:session-id="sessionId"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</N8nScrollArea>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
.scrollable {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
padding-bottom: var(--spacing--lg);
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
@ -487,243 +344,63 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
/* Suggestions */
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: var(--spacing--md);
|
||||
width: min(960px, 90%);
|
||||
margin-top: var(--spacing--md);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.suggestions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--md);
|
||||
border: 1px solid var(--color--foreground);
|
||||
background: var(--color--background);
|
||||
border-radius: var(--radius--lg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.06s ease,
|
||||
background 0.06s ease,
|
||||
border-color 0.06s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--color--primary);
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
}
|
||||
.cardIcon {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.cardText {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.threadWrap {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.threadContainer {
|
||||
width: min(960px, 90%);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.thread {
|
||||
padding: var(--spacing--md);
|
||||
background: var(--color--background--light-2);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: var(--spacing--sm);
|
||||
margin-bottom: var(--spacing--md);
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color--background--light-3);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.chatMessage {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
padding: var(--spacing--md);
|
||||
border-radius: var(--radius--lg);
|
||||
|
||||
&.chatMessageFromAssistant {
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
|
||||
&.chatMessageFromUser {
|
||||
background-color: var(--color--background--shade-1);
|
||||
}
|
||||
|
||||
> .chatMessageMarkdown {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
font-size: inherit;
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--spacing--xs) 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
padding: var(--chat--spacing);
|
||||
background: var(--chat--message--pre--background);
|
||||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
.starter {
|
||||
.isMobileDevice & {
|
||||
padding-top: 100px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.typing i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.typing i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt */
|
||||
.prompt {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.messageList {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing--md);
|
||||
}
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
max-width: 55rem;
|
||||
min-height: 100vh;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(720px, 50vw);
|
||||
min-width: 320px;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
padding-top: 100px;
|
||||
padding-bottom: 200px;
|
||||
padding-inline: 64px;
|
||||
|
||||
& input:disabled {
|
||||
cursor: not-allowed;
|
||||
.isMobileDevice & {
|
||||
padding-inline: var(--spacing--md);
|
||||
}
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
padding: 14px 112px 14px 14px;
|
||||
border: 1px solid var(--color--foreground);
|
||||
background: var(--color--background--light-2);
|
||||
color: var(--color--text--shade-1);
|
||||
border-radius: 16px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--color--primary);
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
|
||||
|
||||
.promptContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.isMobileDevice &,
|
||||
.hasMessages & {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding-block: var(--spacing--md);
|
||||
background: linear-gradient(transparent 0%, var(--color--background--light-2) 30%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Right-side actions */
|
||||
.actions {
|
||||
.prompt {
|
||||
width: 100%;
|
||||
max-width: 55rem;
|
||||
padding-inline: 64px;
|
||||
|
||||
.isMobileDevice & {
|
||||
padding-inline: var(--spacing--md);
|
||||
}
|
||||
}
|
||||
|
||||
.floating {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: var(--spacing--xs);
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.iconBtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color--text--tint-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
.sendBtn {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--color--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sendBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: var(--spacing--xs);
|
||||
color: var(--color--text--tint-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modelSelector {
|
||||
position: absolute;
|
||||
top: var(--spacing-xs);
|
||||
left: var(--spacing-xs);
|
||||
z-index: 100;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
<script setup lang="ts">
|
||||
import type { ChatMessage } from '@/features/chatHub/chat.types';
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
|
||||
const { message, compact } = defineProps<{ message: ChatMessage; compact: boolean }>();
|
||||
|
||||
function messageText(msg: ChatMessage) {
|
||||
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
|
||||
}
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return ''; // use external default escaping
|
||||
},
|
||||
};
|
||||
|
||||
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.message,
|
||||
message.role === 'user' ? $style.user : $style.assistant,
|
||||
{
|
||||
[$style.compact]: compact,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon :icon="message.role === 'user' ? 'user' : 'sparkles'" width="20" height="20" />
|
||||
</div>
|
||||
<div :class="$style.chatMessage">
|
||||
<VueMarkdown
|
||||
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
|
||||
:source="messageText(message)"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.message {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
margin-right: var(--spacing--xs);
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color--background--light-3);
|
||||
color: var(--color--text--tint-1);
|
||||
|
||||
.compact & {
|
||||
position: static;
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
}
|
||||
|
||||
.chatMessage {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
|
||||
.user & {
|
||||
padding: var(--spacing--md);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
|
||||
> .chatMessageMarkdown {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
font-size: inherit;
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--spacing--xs) 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
padding: var(--chat--spacing);
|
||||
background: var(--chat--message--pre--background);
|
||||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
|
||||
const { disabled, sessionId } = defineProps<{
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
sessionId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [string];
|
||||
}>();
|
||||
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
const message = ref('');
|
||||
|
||||
function onAttach() {}
|
||||
|
||||
function onMic() {}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
setText: (text: string) => {
|
||||
message.value = text;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="$style.prompt" @submit.prevent="emit('submit', message)">
|
||||
<div :class="$style.inputWrap">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="message"
|
||||
:class="$style.input"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
:disabled="disabled"
|
||||
/>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Attach"
|
||||
:disabled="disabled"
|
||||
@click="onAttach"
|
||||
>
|
||||
<N8nIcon icon="paperclip" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
:class="$style.iconBtn"
|
||||
type="button"
|
||||
title="Voice"
|
||||
:disabled="disabled"
|
||||
@click="onMic"
|
||||
>
|
||||
<N8nIcon icon="mic" width="20" height="20" />
|
||||
</button>
|
||||
<button :class="$style.sendBtn" type="submit" :disabled="disabled || !message.trim()">
|
||||
<span v-if="!disabled">Send</span>
|
||||
<span v-else>…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<N8nText :class="$style.disclaimer" color="text-light" size="small">
|
||||
AI may make mistakes. Check important info.
|
||||
<br />
|
||||
{{ sessionId }}
|
||||
</N8nText>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.prompt {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
& input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
padding: 14px 112px 14px 14px;
|
||||
border: 1px solid var(--color--foreground);
|
||||
background: var(--color--background--light-2);
|
||||
color: var(--color--text--shade-1);
|
||||
border-radius: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color--primary);
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Right-side actions */
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color--text--tint-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--color--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: var(--spacing--xs);
|
||||
color: var(--color--text--tint-2);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,117 +1,61 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW } from '@/features/chatHub/constants';
|
||||
import { N8nIcon, N8nIconButton, N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import { useChatStore } from '@/features/chatHub/chat.store';
|
||||
import { groupConversationsByDate } from '@/features/chatHub/chat.utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ModalDrawer from '@/components/ModalDrawer.vue';
|
||||
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY } from '@/constants';
|
||||
import ChatSidebarContent from '@/features/chatHub/components/ChatSidebarContent.vue';
|
||||
import { MOBILE_MEDIA_QUERY } from '@/features/chatHub/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const isMobileDevice = useMediaQuery(MOBILE_MEDIA_QUERY);
|
||||
const route = useRoute();
|
||||
const currentSessionId = computed(() =>
|
||||
typeof route.params.id === 'string' ? route.params.id : undefined,
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY),
|
||||
);
|
||||
const chatStore = useChatStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await chatStore.fetchSessions();
|
||||
onBeforeUnmount(() => {
|
||||
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
|
||||
});
|
||||
|
||||
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
|
||||
|
||||
function onReturn() {
|
||||
void router.push({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
|
||||
function onNewChat() {
|
||||
void router.push({
|
||||
name: CHAT_VIEW,
|
||||
force: true, // to focus input again when the user is already in CHAT_VIEW
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['side-menu', $style.container]">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.returnButton" @click="onReturn">
|
||||
<i>
|
||||
<N8nIcon icon="arrow-left" />
|
||||
</i>
|
||||
<N8nText bold>Chat</N8nText>
|
||||
</div>
|
||||
<N8nIconButton title="New chat" icon="square-pen" type="tertiary" text @click="onNewChat" />
|
||||
</div>
|
||||
<div :class="$style.items">
|
||||
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
|
||||
<N8nText :class="$style.groupHeader" size="xsmall" bold color="text-light">
|
||||
{{ group.group }}
|
||||
</N8nText>
|
||||
<N8nMenuItem
|
||||
v-for="session in group.sessions"
|
||||
:key="session.id"
|
||||
:active="currentSessionId === session.id"
|
||||
:item="{
|
||||
id: session.id,
|
||||
icon: 'message-circle',
|
||||
label: session.label,
|
||||
route: { to: { name: CHAT_CONVERSATION_VIEW, params: { id: session.id } } },
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalDrawer
|
||||
v-if="isMobileDevice"
|
||||
direction="ltr"
|
||||
width="min(240px, 80vw)"
|
||||
:name="CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY"
|
||||
:class="$style.drawer"
|
||||
:close-on-click-modal="true"
|
||||
:show-close="false"
|
||||
>
|
||||
<template #content>
|
||||
<ChatSidebarContent :class="$style.inDrawer" :is-mobile-device="isMobileDevice" />
|
||||
</template>
|
||||
</ModalDrawer>
|
||||
<ChatSidebarContent v-else :class="$style.static" :is-mobile-device="isMobileDevice" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
background-color: var(--color--background--light-3);
|
||||
border-right: var(--border-base);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-xs);
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.returnButton {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--spacing-3xs);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
&:hover {
|
||||
color: var(--color--primary);
|
||||
.drawer {
|
||||
& :global(.el-drawer__header) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--spacing-3xs);
|
||||
gap: var(--spacing-xs);
|
||||
.inDrawer,
|
||||
.static {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
padding: 0 var(--spacing-3xs) var(--spacing-3xs) var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: var(--spacing-xs);
|
||||
text-align: center;
|
||||
.static {
|
||||
width: 200px;
|
||||
background-color: var(--color--background--light-3);
|
||||
border-right: var(--border);
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
<script setup lang="ts">
|
||||
import MainSidebarUserArea from '@/components/MainSidebarUserArea.vue';
|
||||
import { CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import { useChatStore } from '@/features/chatHub/chat.store';
|
||||
import { groupConversationsByDate } from '@/features/chatHub/chat.utils';
|
||||
import { CHAT_CONVERSATION_VIEW, CHAT_VIEW } from '@/features/chatHub/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { N8nIcon, N8nIconButton, N8nMenuItem, N8nScrollArea, N8nText } from '@n8n/design-system';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
defineProps<{ isMobileDevice: boolean }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const currentSessionId = computed(() =>
|
||||
typeof route.params.id === 'string' ? route.params.id : undefined,
|
||||
);
|
||||
|
||||
const groupedConversations = computed(() => groupConversationsByDate(chatStore.sessions));
|
||||
|
||||
function onReturn() {
|
||||
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
|
||||
|
||||
void router.push({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
|
||||
function onNewChat() {
|
||||
uiStore.closeModal(CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY);
|
||||
|
||||
void router.push({
|
||||
name: CHAT_VIEW,
|
||||
force: true, // to focus input again when the user is already in CHAT_VIEW
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await chatStore.fetchSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.component, { [$style.isMobileDevice]: isMobileDevice }]">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.returnButton" role="button" @click="onReturn">
|
||||
<N8nIcon icon="arrow-left" />
|
||||
<N8nText bold>Chat</N8nText>
|
||||
</div>
|
||||
<N8nIconButton
|
||||
title="New chat"
|
||||
icon="square-pen"
|
||||
type="tertiary"
|
||||
text
|
||||
:size="isMobileDevice ? 'large' : 'medium'"
|
||||
@click="onNewChat"
|
||||
/>
|
||||
</div>
|
||||
<N8nScrollArea as-child>
|
||||
<div :class="$style.items">
|
||||
<div v-for="group in groupedConversations" :key="group.group" :class="$style.group">
|
||||
<N8nText :class="$style.groupHeader" size="small" bold color="text-light">
|
||||
{{ group.group }}
|
||||
</N8nText>
|
||||
<N8nMenuItem
|
||||
v-for="session in group.sessions"
|
||||
:key="session.id"
|
||||
:active="currentSessionId === session.id"
|
||||
:item="{
|
||||
id: session.id,
|
||||
icon: 'message-circle',
|
||||
label: session.label,
|
||||
route: { to: { name: CHAT_CONVERSATION_VIEW, params: { id: session.id } } },
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<MainSidebarUserArea :fully-expanded="true" :is-collapsed="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing--xs);
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.returnButton {
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
gap: var(--spacing--3xs);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--color--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--spacing--3xs);
|
||||
gap: var(--spacing--xs);
|
||||
|
||||
.isMobileDevice & {
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
padding: 0 var(--spacing--3xs) var(--spacing--3xs) var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: var(--spacing--xs);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import type { Suggestion } from '@/features/chatHub/chat.types';
|
||||
import { SUGGESTIONS } from '@/features/chatHub/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { N8nHeading, N8nText } from '@n8n/design-system';
|
||||
import Logo from '@n8n/design-system/components/N8nLogo/Logo.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps<{ isMobileDevice: boolean }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [Suggestion];
|
||||
}>();
|
||||
|
||||
const userStore = useUsersStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const greetings = computed(
|
||||
() =>
|
||||
`Good morning, ${userStore.currentUser?.firstName ?? userStore.currentUser?.fullName ?? 'User'}!`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.starter, { [$style.isMobileDevice]: isMobileDevice }]">
|
||||
<Logo size="large" :release-channel="settingsStore.settings.releaseChannel" />
|
||||
|
||||
<div :class="$style.header">
|
||||
<N8nHeading tag="h2" bold size="xlarge">
|
||||
{{ greetings }}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
|
||||
<div :class="$style.suggestions">
|
||||
<button
|
||||
v-for="suggestion in SUGGESTIONS"
|
||||
:key="suggestion.title"
|
||||
type="button"
|
||||
:class="$style.card"
|
||||
@click="emit('select', suggestion)"
|
||||
>
|
||||
<div :class="$style.cardIcon" aria-hidden="true">
|
||||
<N8nText size="xlarge">{{ suggestion.icon }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.cardText">
|
||||
<N8nText bold color="text-dark">{{ suggestion.title }}</N8nText>
|
||||
<N8nText color="text-base">{{ suggestion.subtitle }}</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.starter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: var(--spacing--md);
|
||||
width: min(960px, 90%);
|
||||
|
||||
.isMobileDevice & {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--md);
|
||||
border: 1px solid var(--color--foreground);
|
||||
background: var(--color--background);
|
||||
border-radius: var(--radius--lg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.06s ease,
|
||||
background 0.06s ease,
|
||||
border-color 0.06s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color--primary);
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<span :class="$style.typing"><i></i><i></i><i></i></span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.typing {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
|
||||
.typing i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -130,7 +130,7 @@ function onCancel() {
|
|||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
gap: var(--spacing--2xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,9 @@ function onSelect(id: string) {
|
|||
:class="$style.icon"
|
||||
/>
|
||||
<div :class="$style.selected">
|
||||
{{ selectedLabel }}
|
||||
<div>
|
||||
{{ selectedLabel }}
|
||||
</div>
|
||||
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
|
||||
{{ credentialsName }}
|
||||
</N8nText>
|
||||
|
|
@ -120,6 +122,13 @@ function onSelect(id: string) {
|
|||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: var(--spacing--4xs);
|
||||
max-width: 200px;
|
||||
|
||||
& > div {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
|
|||
|
|
@ -35,3 +35,5 @@ export const providerDisplayNames: Record<ChatHubProvider, string> = {
|
|||
anthropic: 'Anthropic',
|
||||
google: 'Google',
|
||||
};
|
||||
|
||||
export const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user