mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: AI workflow builder front-end (no-changelog) (#14820)
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
@@ -200,7 +200,7 @@ const renameKeyCode = ' ';
|
||||
useShortKeyPress(
|
||||
renameKeyCode,
|
||||
() => {
|
||||
if (lastSelectedNode.value) {
|
||||
if (lastSelectedNode.value && lastSelectedNode.value.id !== CanvasNodeRenderType.AIPrompt) {
|
||||
emit('update:node:name', lastSelectedNode.value.id);
|
||||
}
|
||||
},
|
||||
@@ -296,7 +296,7 @@ const keyMap = computed(() => {
|
||||
ctrl_alt_n: () => emit('create:workflow'),
|
||||
ctrl_enter: () => emit('run:workflow'),
|
||||
ctrl_s: () => emit('save:workflow'),
|
||||
shift_alt_t: async () => await onTidyUp('keyboard-shortcut'),
|
||||
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
|
||||
};
|
||||
return fullKeymap;
|
||||
});
|
||||
@@ -658,16 +658,16 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
|
||||
case 'change_color':
|
||||
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
|
||||
case 'tidy_up':
|
||||
return await onTidyUp('context-menu');
|
||||
return await onTidyUp({ source: 'context-menu' });
|
||||
}
|
||||
}
|
||||
|
||||
async function onTidyUp(source: CanvasLayoutSource) {
|
||||
async function onTidyUp(payload: { source: CanvasLayoutSource }) {
|
||||
const applyOnSelection = selectedNodes.value.length > 1;
|
||||
const target = applyOnSelection ? 'selection' : 'all';
|
||||
const result = layout(target);
|
||||
|
||||
emit('tidy-up', { result, target, source });
|
||||
emit('tidy-up', { result, target, source: payload.source });
|
||||
|
||||
if (!applyOnSelection) {
|
||||
await nextTick();
|
||||
@@ -749,14 +749,14 @@ const initialized = ref(false);
|
||||
onMounted(() => {
|
||||
props.eventBus.on('fitView', onFitView);
|
||||
props.eventBus.on('nodes:select', onSelectNodes);
|
||||
|
||||
props.eventBus.on('tidyUp', onTidyUp);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
props.eventBus.off('fitView', onFitView);
|
||||
props.eventBus.off('nodes:select', onSelectNodes);
|
||||
|
||||
props.eventBus.off('tidyUp', onTidyUp);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
});
|
||||
|
||||
@@ -900,7 +900,7 @@ provide(CanvasKey, {
|
||||
@zoom-in="onZoomIn"
|
||||
@zoom-out="onZoomOut"
|
||||
@reset-zoom="onResetZoom"
|
||||
@tidy-up="onTidyUp('canvas-button')"
|
||||
@tidy-up="onTidyUp({ source: 'canvas-button' })"
|
||||
/>
|
||||
|
||||
<Suspense>
|
||||
|
||||
@@ -288,7 +288,9 @@ provide(CanvasNodeKey, {
|
||||
eventBus: canvasNodeEventBus,
|
||||
});
|
||||
|
||||
const hasToolbar = computed(() => props.data.type !== CanvasNodeRenderType.AddNodes);
|
||||
const hasToolbar = computed(
|
||||
() => ![CanvasNodeRenderType.AddNodes, CanvasNodeRenderType.AIPrompt].includes(renderType.value),
|
||||
);
|
||||
|
||||
const showToolbar = computed(() => {
|
||||
const target = contextMenu.target.value;
|
||||
@@ -392,6 +394,7 @@ onBeforeUnmount(() => {
|
||||
@move="onMove"
|
||||
@update="onUpdate"
|
||||
@open:contextmenu="onOpenContextMenuFromNode"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
|
||||
<CanvasNodeTrigger
|
||||
|
||||
@@ -3,6 +3,7 @@ import { h, inject } from 'vue';
|
||||
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
|
||||
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
|
||||
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
|
||||
import CanvasNodeAIPrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeAIPrompt.vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
import { CanvasNodeRenderType } from '@/types';
|
||||
|
||||
@@ -19,6 +20,9 @@ const Render = () => {
|
||||
case CanvasNodeRenderType.AddNodes:
|
||||
Component = CanvasNodeAddNodes;
|
||||
break;
|
||||
case CanvasNodeRenderType.AIPrompt:
|
||||
Component = CanvasNodeAIPrompt;
|
||||
break;
|
||||
default:
|
||||
Component = CanvasNodeDefault;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
import { useBuilderStore } from '@/stores/builder.store';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: string];
|
||||
}>();
|
||||
const i18n = useI18n();
|
||||
|
||||
const { id } = useCanvasNode();
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
const isPromptVisible = ref(true);
|
||||
const isFocused = ref(false);
|
||||
|
||||
const prompt = ref('');
|
||||
const hasContent = computed(() => prompt.value.trim().length > 0);
|
||||
|
||||
async function onSubmit() {
|
||||
builderStore.openChat();
|
||||
emit('delete', id.value);
|
||||
await builderStore.initBuilderChat(prompt.value, 'canvas');
|
||||
isPromptVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isPromptVisible" :class="$style.container" data-test-id="canvas-ai-prompt">
|
||||
<div :class="[$style.promptContainer, { [$style.focused]: isFocused }]">
|
||||
<form :class="$style.form" @submit.prevent="onSubmit">
|
||||
<n8n-input
|
||||
v-model="prompt"
|
||||
:class="$style.form_textarea"
|
||||
type="textarea"
|
||||
:disabled="builderStore.streaming"
|
||||
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
|
||||
:read-only="false"
|
||||
:rows="15"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.meta.enter.stop="onSubmit"
|
||||
/>
|
||||
<div :class="$style.form_footer">
|
||||
<n8n-button
|
||||
native-type="submit"
|
||||
:disabled="!hasContent || builderStore.streaming"
|
||||
@keydown.enter="onSubmit"
|
||||
>{{ i18n.baseText('aiAssistant.builder.buildWorkflow') }}</n8n-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div :class="$style.or">
|
||||
<p :class="$style.or_text">or</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.promptContainer {
|
||||
--width: 620px;
|
||||
--height: 150px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xs);
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-foreground-dark);
|
||||
background-color: var(--color-background-xlight);
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
|
||||
&.focused {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.form_textarea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
|
||||
:global(.el-textarea__inner) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
}
|
||||
|
||||
.form_footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.or {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
height: 100px;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.or_text {
|
||||
font-size: var(--font-size-m);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user