This commit is contained in:
autologie 2025-03-05 17:21:35 +01:00 committed by GitHub
commit 5bb7383e82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 409 additions and 256 deletions

View file

@ -1,91 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Ref } from 'vue'; import { computed, ref, watchEffect } from 'vue';
import { provide, watch, computed, ref, watchEffect } from 'vue';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import type { Router } from 'vue-router';
import { useRouter } from 'vue-router';
import { chatEventBus } from '@n8n/chat/event-buses';
import { VIEWS } from '@/constants';
import { v4 as uuid } from 'uuid';
// Components // Components
import ChatMessagesPanel from './components/ChatMessagesPanel.vue'; import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
import ChatLogsPanel from './components/ChatLogsPanel.vue'; import ChatLogsPanel from './components/ChatLogsPanel.vue';
// Composables // Composables
import { useChatTrigger } from './composables/useChatTrigger';
import { useChatMessaging } from './composables/useChatMessaging';
import { useResize } from './composables/useResize'; import { useResize } from './composables/useResize';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
// Types // Types
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const router = useRouter();
// Component state // Component state
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const isDisabled = ref(false); const isDisabled = ref(false);
const container = ref<HTMLElement>(); const container = ref<HTMLElement>();
// Computed properties // Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const allConnections = computed(() => workflowsStore.allConnections);
const isChatOpen = computed(() => { const isChatOpen = computed(() => {
const result = workflowsStore.isChatPanelOpen; const result = workflowsStore.isChatPanelOpen;
return result; return result;
}); });
const canvasNodes = computed(() => workflowsStore.allNodes);
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen); const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages); const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData); const resultData = computed(() => workflowsStore.getWorkflowRunData);
// Expose internal state for testing
defineExpose({
messages,
currentSessionId,
isDisabled,
workflow,
});
const { runWorkflow } = useRunWorkflow({ router });
// Initialize features with injected dependencies
const {
chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow,
canvasNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
connectedNode,
messages,
sessionId: currentSessionId,
workflow,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow,
});
const { const {
height, height,
@ -97,170 +42,28 @@ const {
onWindowResize, onWindowResize,
} = useResize(container); } = useResize(container);
// Extracted pure functions for better testability const {
function createChatConfig(params: { currentSessionId,
messages: Chat['messages']; messages,
sendMessage: Chat['sendMessage']; chatTriggerNode,
currentSessionId: Chat['currentSessionId']; connectedNode,
isLoading: Ref<boolean>; sendMessage,
isDisabled: Ref<boolean>; refreshSession,
allowFileUploads: Ref<boolean>; displayExecution,
locale: ReturnType<typeof useI18n>; } = useChatState(isDisabled, onWindowResize);
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const chatOptions: ChatOptions = { // Expose internal state for testing
i18n: { defineExpose({
en: { messages,
title: '', currentSessionId,
footer: '', isDisabled,
subtitle: '', workflow,
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'), });
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes,
};
return { chatConfig, chatOptions };
}
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
const route = params.router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: params.workflowId, executionId: params.executionId },
});
window.open(route.href, '_blank');
}
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
params.messages.value = [];
params.currentSessionId.value = uuid().replace(/-/g, '');
}
// Event handlers
const handleDisplayExecution = (executionId: string) => {
displayExecution({
router,
workflowId: workflow.value.id,
executionId,
});
};
const handleRefreshSession = () => {
refreshSession({
messages,
currentSessionId,
});
};
const closePanel = () => { const closePanel = () => {
workflowsStore.setPanelOpen('chat', false); workflowsStore.setPanelOpen('chat', false);
}; };
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {
return await new Promise<void>((resolve) => {
const resolveIfFinished = (isRunning: boolean) => {
if (!isRunning) {
unwatch();
resolve();
}
};
// Watch for changes in the workflow execution status
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
resolveIfFinished(workflowsStore.isWorkflowRunning);
});
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
};
if (workflowsStore.chatPartialExecutionDestinationNode) {
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
workflowsStore.chatPartialExecutionDestinationNode = null;
}
const response = await runWorkflow(runWorkflowOptions);
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message);
return response;
}
return;
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled,
allowFileUploads,
locale: useI18n(),
});
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => isChatOpen.value,
(isOpen) => {
if (isOpen) {
setChatTriggerNode();
setConnectedNode();
if (messages.value.length === 0) {
messages.value = getChatMessages();
}
setTimeout(() => {
onWindowResize();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
watchEffect(() => { watchEffect(() => {
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0); canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
}); });
@ -293,8 +96,8 @@ watchEffect(() => {
:past-chat-messages="previousChatMessages" :past-chat-messages="previousChatMessages"
:show-close-button="!connectedNode" :show-close-button="!connectedNode"
@close="closePanel" @close="closePanel"
@refresh-session="handleRefreshSession" @refresh-session="refreshSession"
@display-execution="handleDisplayExecution" @display-execution="displayExecution"
@send-message="sendMessage" @send-message="sendMessage"
/> />
</div> </div>

View file

@ -18,15 +18,17 @@ interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
sessionId: string; sessionId: string;
showCloseButton?: boolean; showCloseButton?: boolean;
isOpen?: boolean;
} }
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), { isOpen: true });
const emit = defineEmits<{ const emit = defineEmits<{
displayExecution: [id: string]; displayExecution: [id: string];
sendMessage: [message: string]; sendMessage: [message: string];
refreshSession: []; refreshSession: [];
close: []; close: [];
clickHeader: [];
}>(); }>();
const messageComposable = useMessage(); const messageComposable = useMessage();
@ -142,10 +144,9 @@ function copySessionId() {
</script> </script>
<template> <template>
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog"> <div :class="[$style.chat, isOpen ? $style.isOpen : null]" data-test-id="workflow-lm-chat-dialog">
<header :class="$style.chatHeader"> <PanelHeader :title="locale.baseText('chat.window.title')" @click="emit('clickHeader')">
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span> <template #actions>
<div :class="$style.session">
<span>{{ locale.baseText('chat.window.session.title') }}</span> <span>{{ locale.baseText('chat.window.session.title') }}</span>
<n8n-tooltip placement="left"> <n8n-tooltip placement="left">
<template #content> <template #content>
@ -174,9 +175,9 @@ function copySessionId() {
icon="times" icon="times"
@click="emit('close')" @click="emit('close')"
/> />
</div> </template>
</header> </PanelHeader>
<main :class="$style.chatBody"> <main v-if="isOpen" :class="$style.chatBody">
<MessagesList :messages="messages" :class="$style.messages"> <MessagesList :messages="messages" :class="$style.messages">
<template #beforeMessage="{ message }"> <template #beforeMessage="{ message }">
<MessageOptionTooltip <MessageOptionTooltip
@ -209,7 +210,7 @@ function copySessionId() {
</MessagesList> </MessagesList>
</main> </main>
<div :class="$style.messagesInput"> <div v-if="isOpen" :class="$style.messagesInput">
<ChatInput <ChatInput
data-test-id="lm-chat-inputs" data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
@ -265,28 +266,7 @@ function copySessionId() {
overflow: hidden; overflow: hidden;
background-color: var(--color-background-light); background-color: var(--color-background-light);
} }
.chatHeader {
font-size: var(--font-size-s);
font-weight: 400;
line-height: 18px;
text-align: left;
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--chat--spacing);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
}
.chatTitle {
font-weight: 600;
}
.session {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-base);
max-width: 70%;
}
.sessionId { .sessionId {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
@ -295,10 +275,6 @@ function copySessionId() {
cursor: pointer; cursor: pointer;
} }
.headerButton {
max-height: 1.1rem;
border: none;
}
.chatBody { .chatBody {
display: flex; display: flex;
height: 100%; height: 100%;

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
defineProps<{ title: string }>();
defineSlots<{ actions: {} }>();
const emit = defineEmits<{ click: [] }>();
</script>
<template>
<header :class="$style.container" @click="emit('click')">
<span :class="$style.title">{{ title }}</span>
<div :class="$style.actions">
<slot name="actions" />
</div>
</header>
</template>
<style lang="scss" module>
.container {
font-size: var(--font-size-s);
font-weight: 400;
line-height: 18px;
text-align: left;
padding: var(--spacing-xs);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
&:not(:last-child) {
border-bottom: 1px solid var(--color-foreground-base);
}
}
.title {
font-weight: 600;
flex-grow: 1;
flex-shrink: 1;
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-base);
max-width: 70%;
}
.actions button {
max-height: 1.1rem;
border: none;
}
</style>

View file

@ -0,0 +1,229 @@
import type { RunWorkflowChatPayload } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatMessaging } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatTrigger } from '@/components/CanvasChat/composables/useChatTrigger';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { VIEWS } from '@/constants';
import { type INodeUi } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import { type INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
interface ChatState {
currentSessionId: Ref<string>;
messages: Ref<ChatMessage[]>;
chatTriggerNode: Ref<INodeUi | null>;
connectedNode: Ref<INode | null>;
sendMessage: (message: string, files?: File[]) => Promise<void>;
refreshSession: () => void;
displayExecution: (executionId: string) => void;
}
export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => void): ChatState {
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const canvasStore = useCanvasStore();
const router = useRouter();
const nodeHelpers = useNodeHelpers();
const { runWorkflow } = useRunWorkflow({ router });
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const canvasNodes = computed(() => workflowsStore.allNodes);
const allConnections = computed(() => workflowsStore.allConnections);
const isChatOpen = computed(() => {
const result = workflowsStore.isChatPanelOpen;
return result;
});
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies
const {
chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow,
canvasNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
connectedNode,
messages,
sessionId: currentSessionId,
workflow,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow,
});
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
sendMessage: Chat['sendMessage'];
currentSessionId: Chat['currentSessionId'];
isLoading: Ref<boolean>;
isDisabled: Ref<boolean>;
allowFileUploads: Ref<boolean>;
locale: ReturnType<typeof useI18n>;
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes,
};
return { chatConfig, chatOptions };
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled,
allowFileUploads,
locale: useI18n(),
});
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => isChatOpen.value,
(isOpen) => {
if (isOpen) {
setChatTriggerNode();
setConnectedNode();
if (messages.value.length === 0) {
messages.value = getChatMessages();
}
setTimeout(() => {
onWindowResize();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {
return await new Promise<void>((resolve) => {
const resolveIfFinished = (isRunning: boolean) => {
if (!isRunning) {
unwatch();
resolve();
}
};
// Watch for changes in the workflow execution status
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
resolveIfFinished(workflowsStore.isWorkflowRunning);
});
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
};
if (workflowsStore.chatPartialExecutionDestinationNode) {
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
workflowsStore.chatPartialExecutionDestinationNode = null;
}
const response = await runWorkflow(runWorkflowOptions);
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message);
return response;
}
return;
}
function refreshSession() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
messages.value = [];
currentSessionId.value = uuid().replace(/-/g, '');
}
function displayExecution(executionId: string) {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId },
});
window.open(route.href, '_blank');
}
return {
currentSessionId,
messages,
chatTriggerNode,
connectedNode,
sendMessage,
refreshSession,
displayExecution,
};
}

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, ref } from 'vue';
import { N8nIconButton } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { useResize } from '@/components/CanvasChat/composables/useResize';
const workflowsStore = useWorkflowsStore();
const isOpen = computed(() => workflowsStore.isChatPanelOpen || workflowsStore.isLogsPanelOpen);
const container = ref<HTMLElement>();
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const { onWindowResize } = useResize(container);
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
useChatState(ref(false), onWindowResize);
function handleToggleOpen() {
workflowsStore.setPanelOpen('chat', !isOpen.value);
}
function handleClickHeader() {
if (!isOpen.value) {
workflowsStore.setPanelOpen('chat', true);
}
}
</script>
<template>
<div ref="container" :class="$style.container">
<ChatMessagesPanel
data-test-id="canvas-chat"
:is-open="isOpen"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="!connectedNode"
@close="handleToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="handleClickHeader"
/>
<LogsPanel :is-open="isOpen" @click-header="handleClickHeader">
<template #actions
><N8nIconButton
type="secondary"
size="mini"
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
@click.stop="handleToggleOpen"
/></template>
</LogsPanel>
</div>
</template>
<style lang="scss" module>
.container {
border-top: 1px solid var(--color-foreground-base);
background-color: var(--color-background-light);
display: flex;
}
</style>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{ isOpen: boolean }>();
const emit = defineEmits<{ clickHeader: [] }>();
defineSlots<{ actions: {} }>();
</script>
<template>
<div :class="$style.container">
<PanelHeader title="Logs" @click="emit('clickHeader')">
<template #actions>
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="$style.content">body...</div>
</div>
</template>
<style lang="scss" module>
.container {
flex-grow: 1;
flex-shrink: 1;
}
.content {
padding: var(--spacing-2xs);
}
</style>

View file

@ -26,7 +26,8 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue'); const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue'); const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
const MainSidebar = async () => await import('@/components/MainSidebar.vue'); const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue'); const CanvasBottomPanel = async () =>
await import('@/components/CanvasChat/v2/CanvasBottomPanel.vue');
const NodeView = async () => await import('@/views/NodeView.vue'); const NodeView = async () => await import('@/views/NodeView.vue');
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue'); const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
const WorkflowExecutionsLandingPage = async () => const WorkflowExecutionsLandingPage = async () =>
@ -358,7 +359,7 @@ export const routes: RouteRecordRaw[] = [
default: NodeView, default: NodeView,
header: MainHeader, header: MainHeader,
sidebar: MainSidebar, sidebar: MainSidebar,
footer: CanvasChat, footer: CanvasBottomPanel,
}, },
meta: { meta: {
nodeView: true, nodeView: true,
@ -391,7 +392,7 @@ export const routes: RouteRecordRaw[] = [
default: NodeView, default: NodeView,
header: MainHeader, header: MainHeader,
sidebar: MainSidebar, sidebar: MainSidebar,
footer: CanvasChat, footer: CanvasBottomPanel,
}, },
meta: { meta: {
nodeView: true, nodeView: true,