mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Always show collapsed panel at the bottom of canvas (#13715)
This commit is contained in:
@@ -8,6 +8,7 @@ import type { ChatMessage } from '@n8n/chat/types';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
emptyText?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
@@ -29,7 +30,19 @@ watch(
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-messages-list">
|
<div
|
||||||
|
v-if="emptyText && initialMessages.length === 0 && messages.length === 0"
|
||||||
|
class="empty-container"
|
||||||
|
>
|
||||||
|
<div class="empty">
|
||||||
|
<N8nIcon icon="comment" size="large" class="emptyIcon" />
|
||||||
|
<N8nText tag="p" size="medium" color="text-base">
|
||||||
|
{{ emptyText }}
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="chat-messages-list">
|
||||||
<Message
|
<Message
|
||||||
v-for="initialMessage in initialMessages"
|
v-for="initialMessage in initialMessages"
|
||||||
:key="initialMessage.id"
|
:key="initialMessage.id"
|
||||||
@@ -53,4 +66,45 @@ watch(
|
|||||||
display: block;
|
display: block;
|
||||||
padding: var(--chat--messages-list--padding);
|
padding: var(--chat--messages-list--padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-container {
|
||||||
|
container-type: size;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 16em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding-inline: var(--spacing-m);
|
||||||
|
padding-bottom: 1.5em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
zoom: 2.5;
|
||||||
|
color: var(--color-button-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (height < 150px) {
|
||||||
|
.empty {
|
||||||
|
flex-direction: row;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
zoom: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const ariaBusy = computed(() => (props.loading ? 'true' : undefined));
|
|||||||
const ariaDisabled = computed(() => (props.disabled ? 'true' : undefined));
|
const ariaDisabled = computed(() => (props.disabled ? 'true' : undefined));
|
||||||
const isDisabled = computed(() => props.disabled || props.loading);
|
const isDisabled = computed(() => props.disabled || props.loading);
|
||||||
|
|
||||||
const iconSize = computed(() => (props.size === 'mini' ? 'xsmall' : props.size));
|
const iconSize = computed(() => props.iconSize ?? (props.size === 'mini' ? 'xsmall' : props.size));
|
||||||
|
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type IconSize } from './icon';
|
||||||
import type { TextFloat } from './text';
|
import type { TextFloat } from './text';
|
||||||
|
|
||||||
const BUTTON_ELEMENT = ['button', 'a'] as const;
|
const BUTTON_ELEMENT = ['button', 'a'] as const;
|
||||||
@@ -20,6 +21,7 @@ export interface IconButtonProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
|
iconSize?: Exclude<IconSize, 'xlarge'>;
|
||||||
text?: boolean;
|
text?: boolean;
|
||||||
type?: ButtonType;
|
type?: ButtonType;
|
||||||
nativeType?: ButtonNativeType;
|
nativeType?: ButtonNativeType;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useStyles } from '@/composables/useStyles';
|
import { useStyles } from '@/composables/useStyles';
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||||
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
@@ -11,6 +12,8 @@ const assistantStore = useAssistantStore();
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const bottom = computed(() => (ndvStore.activeNode === null ? canvasStore.panelHeight : 0));
|
||||||
|
|
||||||
const lastUnread = computed(() => {
|
const lastUnread = computed(() => {
|
||||||
const msg = assistantStore.lastUnread;
|
const msg = assistantStore.lastUnread;
|
||||||
@@ -41,7 +44,7 @@ const onClick = () => {
|
|||||||
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||||
:class="$style.container"
|
:class="$style.container"
|
||||||
data-test-id="ask-assistant-floating-button"
|
data-test-id="ask-assistant-floating-button"
|
||||||
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
|
:style="{ bottom: `${bottom}px` }"
|
||||||
>
|
>
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||||
@@ -64,8 +67,8 @@ const onClick = () => {
|
|||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
|
margin: var(--spacing-s);
|
||||||
right: var(--spacing-s);
|
right: 0;
|
||||||
z-index: var(--z-index-ask-assistant-floating-button);
|
z-index: var(--z-index-ask-assistant-floating-button);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Ref } from 'vue';
|
import { computed, ref, watchEffect, useTemplateRef } from 'vue';
|
||||||
import { provide, watch, computed, ref, watchEffect, useTemplateRef } 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 { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
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>();
|
||||||
const pipContainer = useTemplateRef('pipContainer');
|
const pipContainer = useTemplateRef('pipContainer');
|
||||||
@@ -47,49 +28,12 @@ const pipContent = useTemplateRef('pipContent');
|
|||||||
// Computed properties
|
// Computed properties
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
const allConnections = computed(() => workflowsStore.allConnections);
|
|
||||||
const chatPanelState = computed(() => workflowsStore.chatPanelState);
|
const chatPanelState = computed(() => workflowsStore.chatPanelState);
|
||||||
const canvasNodes = computed(() => workflowsStore.allNodes);
|
|
||||||
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 telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
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,
|
||||||
chatWidth,
|
chatWidth,
|
||||||
@@ -116,175 +60,34 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.setPanelState('closed');
|
workflowsStore.setPanelState('closed');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 onPopOut() {
|
function onPopOut() {
|
||||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||||
workflowsStore.setPanelState('floating');
|
workflowsStore.setPanelState('floating');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Watchers
|
||||||
watch(
|
|
||||||
chatPanelState,
|
|
||||||
(state) => {
|
|
||||||
if (state !== 'closed') {
|
|
||||||
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(chatPanelState.value === 'attached' ? height.value : 0);
|
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
|
||||||
});
|
});
|
||||||
@@ -319,8 +122,8 @@ watchEffect(() => {
|
|||||||
:past-chat-messages="previousChatMessages"
|
:past-chat-messages="previousChatMessages"
|
||||||
:show-close-button="!isPoppedOut && !connectedNode"
|
:show-close-button="!isPoppedOut && !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>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
|
const { isNewLogsEnabled } = useSettingsStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LogsPanel v-if="isNewLogsEnabled" />
|
||||||
|
<CanvasChat v-else />
|
||||||
|
</template>
|
||||||
@@ -10,21 +10,29 @@ import ChatInput from '@n8n/chat/components/Input.vue';
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
|
||||||
|
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pastChatMessages: string[];
|
pastChatMessages: string[];
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
|
isNewLogsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isOpen: true,
|
||||||
|
isNewLogsEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
displayExecution: [id: string];
|
displayExecution: [id: string];
|
||||||
sendMessage: [message: string];
|
sendMessage: [message: string];
|
||||||
refreshSession: [];
|
refreshSession: [];
|
||||||
close: [];
|
close: [];
|
||||||
|
clickHeader: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
@@ -33,6 +41,12 @@ const toast = useToast();
|
|||||||
|
|
||||||
const previousMessageIndex = ref(0);
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
|
const sessionIdText = computed(() =>
|
||||||
|
locale.baseText('chat.window.session.id', {
|
||||||
|
interpolate: { id: `${props.sessionId.slice(0, 5)}...` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const inputPlaceholder = computed(() => {
|
const inputPlaceholder = computed(() => {
|
||||||
if (props.messages.length > 0) {
|
if (props.messages.length > 0) {
|
||||||
return locale.baseText('chat.window.chat.placeholder');
|
return locale.baseText('chat.window.chat.placeholder');
|
||||||
@@ -124,11 +138,49 @@ async function copySessionId() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||||
<header :class="$style.chatHeader">
|
<PanelHeader
|
||||||
|
v-if="isNewLogsEnabled"
|
||||||
|
:title="locale.baseText('chat.window.title')"
|
||||||
|
@click="emit('clickHeader')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<N8nTooltip v-if="clipboard.isSupported.value">
|
||||||
|
<template #content>
|
||||||
|
{{ sessionId }}
|
||||||
|
<br />
|
||||||
|
{{ locale.baseText('chat.window.session.id.copy') }}
|
||||||
|
</template>
|
||||||
|
<N8nButton
|
||||||
|
data-test-id="chat-session-id"
|
||||||
|
type="secondary"
|
||||||
|
size="mini"
|
||||||
|
@click.stop="copySessionId"
|
||||||
|
>{{ sessionIdText }}</N8nButton
|
||||||
|
>
|
||||||
|
</N8nTooltip>
|
||||||
|
<N8nTooltip
|
||||||
|
v-if="messages.length > 0"
|
||||||
|
:content="locale.baseText('chat.window.session.resetSession')"
|
||||||
|
>
|
||||||
|
<N8nIconButton
|
||||||
|
:class="$style.headerButton"
|
||||||
|
data-test-id="refresh-session-button"
|
||||||
|
outline
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
icon-size="medium"
|
||||||
|
icon="undo"
|
||||||
|
:title="locale.baseText('chat.window.session.reset')"
|
||||||
|
@click.stop="onRefreshSession"
|
||||||
|
/>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
|
</PanelHeader>
|
||||||
|
<header v-else :class="$style.chatHeader">
|
||||||
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
|
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
|
||||||
<div :class="$style.session">
|
<div :class="$style.session">
|
||||||
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
||||||
<n8n-tooltip placement="left">
|
<N8nTooltip placement="left">
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ sessionId }}
|
{{ sessionId }}
|
||||||
</template>
|
</template>
|
||||||
@@ -138,8 +190,8 @@ async function copySessionId() {
|
|||||||
@click="clipboard.isSupported.value ? copySessionId() : null"
|
@click="clipboard.isSupported.value ? copySessionId() : null"
|
||||||
>{{ sessionId }}</span
|
>{{ sessionId }}</span
|
||||||
>
|
>
|
||||||
</n8n-tooltip>
|
</N8nTooltip>
|
||||||
<n8n-icon-button
|
<N8nIconButton
|
||||||
:class="$style.headerButton"
|
:class="$style.headerButton"
|
||||||
data-test-id="refresh-session-button"
|
data-test-id="refresh-session-button"
|
||||||
outline
|
outline
|
||||||
@@ -149,7 +201,7 @@ async function copySessionId() {
|
|||||||
:title="locale.baseText('chat.window.session.reset')"
|
:title="locale.baseText('chat.window.session.reset')"
|
||||||
@click="onRefreshSession"
|
@click="onRefreshSession"
|
||||||
/>
|
/>
|
||||||
<n8n-icon-button
|
<N8nIconButton
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
:class="$style.headerButton"
|
:class="$style.headerButton"
|
||||||
outline
|
outline
|
||||||
@@ -160,8 +212,14 @@ async function copySessionId() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main :class="$style.chatBody">
|
<main v-if="isOpen" :class="$style.chatBody">
|
||||||
<MessagesList :messages="messages" :class="$style.messages">
|
<MessagesList
|
||||||
|
:messages="messages"
|
||||||
|
:class="$style.messages"
|
||||||
|
:empty-text="
|
||||||
|
isNewLogsEnabled ? locale.baseText('chat.window.chat.emptyChatMessage.v2') : undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #beforeMessage="{ message }">
|
<template #beforeMessage="{ message }">
|
||||||
<MessageOptionTooltip
|
<MessageOptionTooltip
|
||||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||||
@@ -193,7 +251,7 @@ async 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"
|
||||||
@@ -201,7 +259,7 @@ async function copySessionId() {
|
|||||||
>
|
>
|
||||||
<template v-if="pastChatMessages.length > 0" #leftPanel>
|
<template v-if="pastChatMessages.length > 0" #leftPanel>
|
||||||
<div :class="$style.messagesHistory">
|
<div :class="$style.messagesHistory">
|
||||||
<n8n-button
|
<N8nButton
|
||||||
title="Navigate to previous message"
|
title="Navigate to previous message"
|
||||||
icon="chevron-up"
|
icon="chevron-up"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
@@ -209,7 +267,7 @@ async function copySessionId() {
|
|||||||
size="mini"
|
size="mini"
|
||||||
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
|
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
|
||||||
/>
|
/>
|
||||||
<n8n-button
|
<N8nButton
|
||||||
title="Navigate to next message"
|
title="Navigate to next message"
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
@@ -289,6 +347,9 @@ async function copySessionId() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<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-2xs);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
padding-inline: var(--spacing-s);
|
||||||
|
padding-block: var(--spacing-2xs);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
line-height: var(--font-line-height-compact);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
/** Panel collapsed */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
/** Panel open */
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
max-width: 70%;
|
||||||
|
/* Let button heights not affect the header height */
|
||||||
|
margin-block: calc(-1 * var(--spacing-s));
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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 chatPanelState = computed(() => workflowsStore.chatPanelState);
|
||||||
|
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(
|
||||||
|
() => chatPanelState.value,
|
||||||
|
(state) => {
|
||||||
|
if (state !== 'closed') {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { renderComponent } from '@/__tests__/render';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { createTestNode } from '@/__tests__/mocks';
|
||||||
|
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import { h } from 'vue';
|
||||||
|
|
||||||
|
describe('LogsPanel', () => {
|
||||||
|
let pinia: TestingPinia;
|
||||||
|
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||||
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
return renderComponent(LogsPanel, {
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [{ path: '/', component: () => h('div') }],
|
||||||
|
}),
|
||||||
|
pinia,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
|
||||||
|
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
settingsStore = mockedStore(useSettingsStore);
|
||||||
|
settingsStore.isNewLogsEnabled = true;
|
||||||
|
|
||||||
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders collapsed panel by default', async () => {
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
expect(await rendered.findByText('Logs')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
rendered.queryByText('Nothing to display yet', { exact: false }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chat panel if the workflow has chat trigger', async () => {
|
||||||
|
workflowsStore.workflowTriggerNodes = [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE })];
|
||||||
|
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
expect(await rendered.findByText('Chat')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens collapsed panel when clicked', async () => {
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
await rendered.findByText('Logs');
|
||||||
|
|
||||||
|
await fireEvent.click(rendered.getByText('Logs'));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await rendered.findByText('Nothing to display yet', { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles panel when chevron icon button is clicked', async () => {
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
await rendered.findByText('Logs');
|
||||||
|
|
||||||
|
await fireEvent.click(rendered.getAllByRole('button').pop()!);
|
||||||
|
expect(rendered.getByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.click(rendered.getAllByRole('button').pop()!);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
rendered.queryByText('Nothing to display yet', { exact: false }),
|
||||||
|
).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
|
import { N8nIconButton, N8nResizeWrapper, N8nTooltip } from '@n8n/design-system';
|
||||||
|
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||||
|
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||||
|
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useStyles } from '@/composables/useStyles';
|
||||||
|
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
const panelState = computed(() => workflowsStore.chatPanelState);
|
||||||
|
const container = ref<HTMLElement>();
|
||||||
|
const pipContainer = useTemplateRef('pipContainer');
|
||||||
|
const pipContent = useTemplateRef('pipContent');
|
||||||
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
|
const hasChat = computed(() =>
|
||||||
|
workflowsStore.workflowTriggerNodes.some((node) =>
|
||||||
|
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const locales = useI18n();
|
||||||
|
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
||||||
|
useResize(container);
|
||||||
|
|
||||||
|
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
||||||
|
ref(false),
|
||||||
|
onWindowResize,
|
||||||
|
);
|
||||||
|
const appStyles = useStyles();
|
||||||
|
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
|
||||||
|
|
||||||
|
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||||
|
initialHeight: 400,
|
||||||
|
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||||
|
container: pipContainer,
|
||||||
|
content: pipContent,
|
||||||
|
shouldPopOut: computed(() => panelState.value === 'floating'),
|
||||||
|
onRequestClose: () => {
|
||||||
|
if (panelState.value === 'closed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||||
|
workflowsStore.setPanelState('attached');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleToggleOpen() {
|
||||||
|
if (panelState.value === 'closed') {
|
||||||
|
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||||
|
workflowsStore.setPanelState('attached');
|
||||||
|
} else {
|
||||||
|
telemetry.track('User toggled log view', { new_state: 'collapsed' });
|
||||||
|
workflowsStore.setPanelState('closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickHeader() {
|
||||||
|
if (panelState.value === 'closed') {
|
||||||
|
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||||
|
workflowsStore.setPanelState('attached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopOut() {
|
||||||
|
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||||
|
workflowsStore.setPanelState('floating');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([panelState, height], ([state, h]) => {
|
||||||
|
canvasStore.setPanelHeight(
|
||||||
|
state === 'floating' ? 0 : state === 'attached' ? h : 32 /* collapsed panel height */,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="pipContainer">
|
||||||
|
<div ref="pipContent" :class="$style.pipContent">
|
||||||
|
<N8nResizeWrapper
|
||||||
|
:height="height"
|
||||||
|
:supported-directions="['top']"
|
||||||
|
:is-resizing-enabled="panelState === 'attached'"
|
||||||
|
:style="rootStyles"
|
||||||
|
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
|
||||||
|
@resize="onResizeDebounced"
|
||||||
|
>
|
||||||
|
<div ref="container" :class="$style.container">
|
||||||
|
<N8nResizeWrapper
|
||||||
|
v-if="hasChat"
|
||||||
|
:supported-directions="['right']"
|
||||||
|
:is-resizing-enabled="panelState !== 'closed'"
|
||||||
|
:width="chatWidth"
|
||||||
|
:class="$style.chat"
|
||||||
|
:window="pipWindow"
|
||||||
|
@resize="onResizeChatDebounced"
|
||||||
|
>
|
||||||
|
<ChatMessagesPanel
|
||||||
|
data-test-id="canvas-chat"
|
||||||
|
:is-open="panelState !== 'closed'"
|
||||||
|
:messages="messages"
|
||||||
|
:session-id="currentSessionId"
|
||||||
|
:past-chat-messages="previousChatMessages"
|
||||||
|
:show-close-button="false"
|
||||||
|
:is-new-logs-enabled="true"
|
||||||
|
@close="handleToggleOpen"
|
||||||
|
@refresh-session="refreshSession"
|
||||||
|
@display-execution="displayExecution"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
@click-header="handleClickHeader"
|
||||||
|
/>
|
||||||
|
</N8nResizeWrapper>
|
||||||
|
<LogsOverviewPanel :is-open="panelState !== 'closed'" @click-header="handleClickHeader">
|
||||||
|
<template #actions>
|
||||||
|
<N8nTooltip
|
||||||
|
v-if="canPopOut && !isPoppedOut"
|
||||||
|
:z-index="tooltipZIndex"
|
||||||
|
:content="locales.baseText('runData.panel.actions.popOut')"
|
||||||
|
>
|
||||||
|
<N8nIconButton
|
||||||
|
icon="pop-out"
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
icon-size="medium"
|
||||||
|
@click="onPopOut"
|
||||||
|
/>
|
||||||
|
</N8nTooltip>
|
||||||
|
<N8nTooltip
|
||||||
|
v-if="panelState !== 'floating'"
|
||||||
|
:z-index="tooltipZIndex"
|
||||||
|
:content="
|
||||||
|
locales.baseText(
|
||||||
|
panelState === 'attached'
|
||||||
|
? 'runData.panel.actions.collapse'
|
||||||
|
: 'runData.panel.actions.open',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<N8nIconButton
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
icon-size="medium"
|
||||||
|
:icon="panelState === 'attached' ? 'chevron-down' : 'chevron-up'"
|
||||||
|
style="color: var(--color-text-base)"
|
||||||
|
@click.stop="handleToggleOpen"
|
||||||
|
/>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
|
</LogsOverviewPanel>
|
||||||
|
</div>
|
||||||
|
</N8nResizeWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
@media all and (display-mode: picture-in-picture) {
|
||||||
|
.resizeWrapper {
|
||||||
|
height: 100% !important;
|
||||||
|
max-height: 100vh !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipContent {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeWrapper {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
&.isOpen {
|
||||||
|
height: var(--panel-height);
|
||||||
|
min-height: 4rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
flex-basis: content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
& > *:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--color-foreground-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
width: var(--chat-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
|
||||||
|
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { N8nButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||||
|
|
||||||
|
defineProps<{ isOpen: boolean }>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ clickHeader: [] }>();
|
||||||
|
const locale = useI18n();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeHelpers = useNodeHelpers();
|
||||||
|
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||||
|
|
||||||
|
defineSlots<{ actions: {} }>();
|
||||||
|
|
||||||
|
function onClearExecutionData() {
|
||||||
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<PanelHeader
|
||||||
|
:title="locale.baseText('logs.overview.header.title')"
|
||||||
|
@click="emit('clickHeader')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<N8nTooltip
|
||||||
|
v-if="isClearExecutionButtonVisible"
|
||||||
|
:content="locale.baseText('logs.overview.header.actions.clearExecution.tooltip')"
|
||||||
|
>
|
||||||
|
<N8nButton
|
||||||
|
size="mini"
|
||||||
|
type="secondary"
|
||||||
|
icon="trash"
|
||||||
|
icon-size="medium"
|
||||||
|
@click.stop="onClearExecutionData"
|
||||||
|
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
|
||||||
|
>
|
||||||
|
</N8nTooltip>
|
||||||
|
<slot name="actions" />
|
||||||
|
</template>
|
||||||
|
</PanelHeader>
|
||||||
|
<div v-if="isOpen" :class="[$style.content, $style.empty]">
|
||||||
|
<N8nText tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
||||||
|
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
max-width: 20em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { START_NODE_TYPE } from '@/constants';
|
||||||
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useCanvasOperations } from './useCanvasOperations';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
|
export function useClearExecutionButtonVisible() {
|
||||||
|
const route = useRoute();
|
||||||
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
||||||
|
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
|
||||||
|
const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas);
|
||||||
|
const router = useRouter();
|
||||||
|
const { editableWorkflow } = useCanvasOperations({ router });
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const isReadOnlyEnvironment = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||||
|
const allTriggerNodesDisabled = computed(() =>
|
||||||
|
editableWorkflow.value.nodes
|
||||||
|
.filter((node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type))
|
||||||
|
.every((node) => node.disabled),
|
||||||
|
);
|
||||||
|
|
||||||
|
return computed(
|
||||||
|
() =>
|
||||||
|
!isReadOnlyRoute.value &&
|
||||||
|
!isReadOnlyEnvironment.value &&
|
||||||
|
!isWorkflowRunning.value &&
|
||||||
|
!allTriggerNodesDisabled.value &&
|
||||||
|
workflowExecutionData.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -451,6 +451,7 @@ export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
|
|||||||
export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_BUTTON';
|
export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_BUTTON';
|
||||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||||
|
export const LOCAL_STORAGE_LOGS_2025_SPRING = 'N8N_LOGS_2025_SPRING';
|
||||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
|
|
||||||
export const HIRING_BANNER = `
|
export const HIRING_BANNER = `
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"chat.window.chat.sendButtonText": "Send",
|
"chat.window.chat.sendButtonText": "Send",
|
||||||
"chat.window.chat.provideMessage": "Please provide a message",
|
"chat.window.chat.provideMessage": "Please provide a message",
|
||||||
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
||||||
|
"chat.window.chat.emptyChatMessage.v2": "Send a message below to trigger the chat workflow",
|
||||||
"chat.window.chat.chatMessageOptions.reuseMessage": "Reuse Message",
|
"chat.window.chat.chatMessageOptions.reuseMessage": "Reuse Message",
|
||||||
"chat.window.chat.chatMessageOptions.repostMessage": "Repost Message",
|
"chat.window.chat.chatMessageOptions.repostMessage": "Repost Message",
|
||||||
"chat.window.chat.chatMessageOptions.executionId": "Execution ID",
|
"chat.window.chat.chatMessageOptions.executionId": "Execution ID",
|
||||||
@@ -209,7 +210,10 @@
|
|||||||
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
||||||
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
||||||
"chat.window.session.title": "Session",
|
"chat.window.session.title": "Session",
|
||||||
|
"chat.window.session.id": "Session: {id}",
|
||||||
|
"chat.window.session.id.copy": "(click to copy)",
|
||||||
"chat.window.session.reset": "Reset",
|
"chat.window.session.reset": "Reset",
|
||||||
|
"chat.window.session.resetSession": "Reset chat session",
|
||||||
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
||||||
"chatEmbed.infoTip.link": "More info",
|
"chatEmbed.infoTip.link": "More info",
|
||||||
"chatEmbed.title": "Embed Chat in your website",
|
"chatEmbed.title": "Embed Chat in your website",
|
||||||
@@ -952,6 +956,11 @@
|
|||||||
"readOnlyEnv.cantAdd.project": "You can't add new projects to a protected n8n instance",
|
"readOnlyEnv.cantAdd.project": "You can't add new projects to a protected n8n instance",
|
||||||
"readOnlyEnv.cantAdd.any": "You can't create new workflows or credentials on a protected n8n instance",
|
"readOnlyEnv.cantAdd.any": "You can't create new workflows or credentials on a protected n8n instance",
|
||||||
"readOnlyEnv.cantEditOrRun": "This workflow can't be edited or run manually because it's on a protected instance",
|
"readOnlyEnv.cantEditOrRun": "This workflow can't be edited or run manually because it's on a protected instance",
|
||||||
|
"logs.overview.header.title": "Logs",
|
||||||
|
"logs.overview.header.actions.clearExecution": "Clear execution",
|
||||||
|
"logs.overview.header.actions.clearExecution.tooltip": "Clear execution data",
|
||||||
|
"logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.",
|
||||||
|
"logs.overview.body.empty.action": "Execute the workflow",
|
||||||
"mainSidebar.aboutN8n": "About n8n",
|
"mainSidebar.aboutN8n": "About n8n",
|
||||||
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
||||||
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
||||||
@@ -1732,6 +1741,9 @@
|
|||||||
"runData.trimmedData.title": "Data not viewable yet",
|
"runData.trimmedData.title": "Data not viewable yet",
|
||||||
"runData.trimmedData.message": "It will be available here once the execution has finished.",
|
"runData.trimmedData.message": "It will be available here once the execution has finished.",
|
||||||
"runData.trimmedData.loading": "Loading data",
|
"runData.trimmedData.loading": "Loading data",
|
||||||
|
"runData.panel.actions.collapse": "Collapse panel",
|
||||||
|
"runData.panel.actions.open": "Open panel",
|
||||||
|
"runData.panel.actions.popOut": "Pop out panel",
|
||||||
"saveButton.save": "@:_reusableBaseText.save",
|
"saveButton.save": "@:_reusableBaseText.save",
|
||||||
"saveButton.saved": "Saved",
|
"saveButton.saved": "Saved",
|
||||||
"saveWorkflowButton.hint": "Save workflow",
|
"saveWorkflowButton.hint": "Save workflow",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ 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 CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.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 +358,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChat,
|
footer: CanvasChatSwitch,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
@@ -391,7 +391,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChat,
|
footer: CanvasChatSwitch,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as ldapApi from '@/api/ldap';
|
|||||||
import * as settingsApi from '@/api/settings';
|
import * as settingsApi from '@/api/settings';
|
||||||
import { testHealthEndpoint } from '@/api/templates';
|
import { testHealthEndpoint } from '@/api/templates';
|
||||||
import type { ILdapConfig } from '@/Interface';
|
import type { ILdapConfig } from '@/Interface';
|
||||||
import { STORES, INSECURE_CONNECTION_WARNING } from '@/constants';
|
import { STORES, INSECURE_CONNECTION_WARNING, LOCAL_STORAGE_LOGS_2025_SPRING } from '@/constants';
|
||||||
import { UserManagementAuthenticationMethod } from '@/Interface';
|
import { UserManagementAuthenticationMethod } from '@/Interface';
|
||||||
import type { IDataObject, WorkflowSettings } from 'n8n-workflow';
|
import type { IDataObject, WorkflowSettings } from 'n8n-workflow';
|
||||||
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
|
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
|
||||||
@@ -183,6 +183,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
|
|
||||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||||
|
|
||||||
|
const isNewLogsEnabled = computed(
|
||||||
|
() => useLocalStorage(LOCAL_STORAGE_LOGS_2025_SPRING, '').value === 'true',
|
||||||
|
);
|
||||||
|
|
||||||
const setSettings = (newSettings: FrontendSettings) => {
|
const setSettings = (newSettings: FrontendSettings) => {
|
||||||
settings.value = newSettings;
|
settings.value = newSettings;
|
||||||
userManagement.value = newSettings.userManagement;
|
userManagement.value = newSettings.userManagement;
|
||||||
@@ -434,6 +438,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
isAskAiEnabled,
|
isAskAiEnabled,
|
||||||
isAiCreditsEnabled,
|
isAiCreditsEnabled,
|
||||||
aiCreditsQuota,
|
aiCreditsQuota,
|
||||||
|
isNewLogsEnabled,
|
||||||
reset,
|
reset,
|
||||||
testLdapConnection,
|
testLdapConnection,
|
||||||
getLdapConfig,
|
getLdapConfig,
|
||||||
|
|||||||
@@ -1207,7 +1207,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
nodeMetadata.value = remainingNodeMetadata;
|
nodeMetadata.value = remainingNodeMetadata;
|
||||||
|
|
||||||
// If chat trigger node is removed, close chat
|
// If chat trigger node is removed, close chat
|
||||||
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
|
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
|
||||||
setPanelState('closed');
|
setPanelState('closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
|||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
||||||
|
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -1122,16 +1123,8 @@ const isStopExecutionButtonVisible = computed(
|
|||||||
const isStopWaitingForWebhookButtonVisible = computed(
|
const isStopWaitingForWebhookButtonVisible = computed(
|
||||||
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
|
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
|
||||||
);
|
);
|
||||||
const isClearExecutionButtonVisible = computed(
|
|
||||||
() =>
|
|
||||||
!isReadOnlyRoute.value &&
|
|
||||||
!isReadOnlyEnvironment.value &&
|
|
||||||
!isWorkflowRunning.value &&
|
|
||||||
!allTriggerNodesDisabled.value &&
|
|
||||||
workflowExecutionData.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||||
|
|
||||||
async function onRunWorkflowToNode(id: string) {
|
async function onRunWorkflowToNode(id: string) {
|
||||||
const node = workflowsStore.getNodeById(id);
|
const node = workflowsStore.getNodeById(id);
|
||||||
@@ -1806,7 +1799,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="onStopWaitingForWebhook"
|
@click="onStopWaitingForWebhook"
|
||||||
/>
|
/>
|
||||||
<CanvasClearExecutionDataButton
|
<CanvasClearExecutionDataButton
|
||||||
v-if="isClearExecutionButtonVisible"
|
v-if="isClearExecutionButtonVisible && !settingsStore.isNewLogsEnabled"
|
||||||
@click="onClearExecutionData"
|
@click="onClearExecutionData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user