feat: AI workflow builder front-end (no-changelog) (#14820)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
oleg
2025-04-28 15:38:32 +02:00
committed by GitHub
parent dbffcdc2ff
commit 97055d5714
56 changed files with 3857 additions and 1067 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>