From 98e4b6830f9ae89681f1c9a21e6ee16e93e9e128 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Thu, 2 Oct 2025 15:34:25 +0100 Subject: [PATCH 01/25] Initial work towards this. --- .../src/components/N8nIcon/icons.ts | 2 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../elements/nodes/CanvasNodeRenderer.vue | 4 + .../render-types/CanvasNodeChoicePrompt.vue | 121 ++++++++++++++++++ .../src/composables/useCanvasMapping.ts | 11 ++ .../frontend/editor-ui/src/types/canvas.ts | 9 +- .../frontend/editor-ui/src/views/NodeView.vue | 13 +- 7 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 2628d3993fcb6..cec3da333570a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -204,6 +204,7 @@ import IconLucideUsers from '~icons/lucide/users'; import IconLucideVariable from '~icons/lucide/variable'; import IconLucideVault from '~icons/lucide/vault'; import IconLucideVideo from '~icons/lucide/video'; +import IconLucideWandSparkles from '~icons/lucide/wand-sparkles'; import IconLucideWaypoints from '~icons/lucide/waypoints'; import IconLucideWrench from '~icons/lucide/wrench'; import IconLucideX from '~icons/lucide/x'; @@ -630,6 +631,7 @@ export const updatedIconSet = { users: IconLucideUsers, vault: IconLucideVault, video: IconLucideVideo, + 'wand-sparkles': IconLucideWandSparkles, waypoints: IconLucideWaypoints, wrench: IconLucideWrench, x: IconLucideX, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index f28296df265ef..1b1ac9cd13d77 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -217,6 +217,7 @@ "aiAssistant.builder.canvasPrompt.cancelButton": "Cancel", "aiAssistant.builder.canvasPrompt.startManually.title": "Start manually", "aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node", + "aiAssistant.builder.canvasPrompt.buildWithAI": "Build with AI", "aiAssistant.builder.streamAbortedMessage": "[Task aborted]", "aiAssistant.builder.executeMessage.description": "Complete these steps before executing your workflow:", "aiAssistant.builder.executeMessage.noIssues": "Your workflow is ready to be executed", diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue index 1c301a66d0823..6f0c8a1166cd1 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue @@ -4,6 +4,7 @@ import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/C 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 CanvasNodeChoicePrompt from '@/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue'; import { CanvasNodeKey } from '@/constants'; import { CanvasNodeRenderType } from '@/types'; @@ -23,6 +24,9 @@ const Render = () => { case CanvasNodeRenderType.AIPrompt: Component = CanvasNodeAIPrompt; break; + case CanvasNodeRenderType.ChoicePrompt: + Component = CanvasNodeChoicePrompt; + break; default: Component = CanvasNodeDefault; } diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue new file mode 100644 index 0000000000000..fec3e11dce113 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 3338cb9b5fd7c..689af4dcf6b8b 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -16,6 +16,7 @@ import type { CanvasNode, CanvasNodeAddNodesRender, CanvasNodeAIPromptRender, + CanvasNodeChoicePromptRender, CanvasNodeData, CanvasNodeDefaultRender, CanvasNodeDefaultRenderLabelSize, @@ -103,6 +104,13 @@ export function useCanvasMapping({ }; } + function createChoicePromptRenderType(): CanvasNodeChoicePromptRender { + return { + type: CanvasNodeRenderType.ChoicePrompt, + options: {}, + }; + } + function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { const nodeType = nodeTypeDescriptionByNodeId.value[node.id]; const icon = getNodeIconSource( @@ -143,6 +151,9 @@ export function useCanvasMapping({ case `${CanvasNodeRenderType.AIPrompt}`: acc[node.id] = createAIPromptRenderType(); break; + case `${CanvasNodeRenderType.ChoicePrompt}`: + acc[node.id] = createChoicePromptRenderType(); + break; default: acc[node.id] = createDefaultNodeRenderType(node); } diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts index 4100aed17942f..d08e168ca962a 100644 --- a/packages/frontend/editor-ui/src/types/canvas.ts +++ b/packages/frontend/editor-ui/src/types/canvas.ts @@ -45,6 +45,7 @@ export const enum CanvasNodeRenderType { StickyNote = 'n8n-nodes-base.stickyNote', AddNodes = 'n8n-nodes-internal.addNodes', AIPrompt = 'n8n-nodes-base.aiPrompt', + ChoicePrompt = 'n8n-nodes-internal.choicePrompt', } export type CanvasNodeDefaultRenderLabelSize = 'small' | 'medium' | 'large'; @@ -87,6 +88,11 @@ export type CanvasNodeAIPromptRender = { options: Record; }; +export type CanvasNodeChoicePromptRender = { + type: CanvasNodeRenderType.ChoicePrompt; + options: Record; +}; + export type CanvasNodeStickyNoteRender = { type: CanvasNodeRenderType.StickyNote; options: Partial<{ @@ -134,7 +140,8 @@ export interface CanvasNodeData { | CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender - | CanvasNodeAIPromptRender; + | CanvasNodeAIPromptRender + | CanvasNodeChoicePromptRender; } export type CanvasNode = Node; diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 5c8ef0e7df3cc..3d3f932cbf658 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -1475,6 +1475,7 @@ function removeSourceControlEventBindings() { function addCommandBarEventBindings() { canvasEventBus.on('create:sticky', onCreateSticky); } + function removeCommandBarEventBindings() { canvasEventBus.off('create:sticky', onCreateSticky); } @@ -1803,12 +1804,12 @@ watch( parameters: {}, }; - const aiPromptItem: INodeUi = { - id: CanvasNodeRenderType.AIPrompt, - name: CanvasNodeRenderType.AIPrompt, - type: CanvasNodeRenderType.AIPrompt, + const choicePromptItem: INodeUi = { + id: CanvasNodeRenderType.ChoicePrompt, + name: CanvasNodeRenderType.ChoicePrompt, + type: CanvasNodeRenderType.ChoicePrompt, typeVersion: 1, - position: [-300, -100], + position: [0, 0], parameters: {}, draggable: false, }; @@ -1817,7 +1818,7 @@ watch( builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled && builderStore.assistantMessages.length === 0 - ? [aiPromptItem] + ? [choicePromptItem] : [addNodesItem]; }, ); From 0b45914175944117b3791f3f392fcd3cf8beac78 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Fri, 3 Oct 2025 11:27:09 +0100 Subject: [PATCH 02/25] Adding highlight. --- .../render-types/CanvasNodeChoicePrompt.vue | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue index fec3e11dce113..cc046471674fc 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeChoicePrompt.vue @@ -44,13 +44,17 @@ async function onBuildWithAIClick() {
- + +

{{ i18n.baseText('aiAssistant.builder.canvasPrompt.buildWithAI') }}

@@ -72,25 +76,24 @@ async function onBuildWithAIClick() { flex-direction: column; align-items: center; width: 100px; - - &:hover .button svg path { - fill: var(--color-primary); - } } .button { background: var(--color-foreground-xlight); border: 2px dashed var(--color-foreground-xdark); - border-radius: 8px; + border-radius: var(--border-radius-large); padding: 0; min-width: 100px; min-height: 100px; cursor: pointer; +} + +.aiButtonHighlight { + border-radius: var(--border-radius-large); - &.aiButton { - --button-hover-text-color: var(--color-text-base); - --button-hover-background-color: var(--color-text-base); + &.highlighted { + box-shadow: 0 0 0 7px var(--color-canvas-selected); } } From 67731d0d4688c7a03165c052ab5e9f47a48f7e10 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Fri, 3 Oct 2025 13:24:25 +0100 Subject: [PATCH 03/25] Adding new desgin components for suggestion component. --- .../AskAssistantChat.stories.ts | 36 +++++- .../AskAssistantChat/AskAssistantChat.vue | 61 ++++++++- .../N8nPromptInputSuggestions.stories.ts | 95 ++++++++++++++ .../N8nPromptInputSuggestions.test.ts | 120 ++++++++++++++++++ .../N8nPromptInputSuggestions.vue | 111 ++++++++++++++++ .../N8nPromptInputSuggestions.test.ts.snap | 48 +++++++ .../N8nPromptInputSuggestions/index.ts | 4 + .../design-system/src/components/index.ts | 1 + .../@n8n/design-system/src/types/assistant.ts | 7 + 9 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nPromptInputSuggestions/N8nPromptInputSuggestions.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nPromptInputSuggestions/N8nPromptInputSuggestions.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nPromptInputSuggestions/N8nPromptInputSuggestions.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nPromptInputSuggestions/__snapshots__/N8nPromptInputSuggestions.test.ts.snap create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nPromptInputSuggestions/index.ts diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts index 26911fe49330b..71f5ac14c8068 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts @@ -1,7 +1,7 @@ import type { StoryFn } from '@storybook/vue3-vite'; import AskAssistantChat from './AskAssistantChat.vue'; -import type { ChatUI } from '../../types/assistant'; +import type { ChatUI, WorkflowSuggestion } from '../../types/assistant'; export default { title: 'Assistant/AskAssistantChat', @@ -21,7 +21,7 @@ const Template: StoryFn = (args, { argTypes }) => ({ components: { AskAssistantChat, }, - template: '
', + template: '
', methods, }); @@ -33,6 +33,38 @@ DefaultPlaceholderChat.args = { }, }; +const mockSuggestions: WorkflowSuggestion[] = [ + { + id: 'invoice-pipeline', + summary: 'Invoice processing pipeline', + prompt: + 'Create an invoice parsing workflow using n8n forms. Extract key information and store in Airtable.', + }, + { + id: 'ai-news-digest', + summary: 'Daily AI news digest', + prompt: + 'Create a workflow that fetches the latest AI news every morning at 8 AM and sends a summary via Telegram.', + }, + { + id: 'rag-assistant', + summary: 'RAG knowledge assistant', + prompt: + 'Build a pipeline that accepts PDF files, chunks documents, and creates a chatbot that can answer questions.', + }, +]; + +export const WithSuggestions = Template.bind({}); +WithSuggestions.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + suggestions: mockSuggestions, + creditsQuota: 100, + creditsRemaining: 75, +}; + export const Chat = Template.bind({}); Chat.args = { user: { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 6e332da020c86..e336e0fe673b6 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, useCssModule, watch } import MessageWrapper from './messages/MessageWrapper.vue'; import { useI18n } from '../../composables/useI18n'; -import type { ChatUI, RatingFeedback } from '../../types/assistant'; +import type { ChatUI, RatingFeedback, WorkflowSuggestion } from '../../types/assistant'; import { isToolMessage } from '../../types/assistant'; import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue'; import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue'; @@ -13,6 +13,7 @@ import N8nButton from '../N8nButton'; import N8nIcon from '../N8nIcon'; import N8nPromptInput from '../N8nPromptInput'; import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue'; +import N8nPromptInputSuggestions from '../N8nPromptInputSuggestions'; import { getSupportedMessageComponent } from './messages/helpers'; const { t } = useI18n(); @@ -35,6 +36,7 @@ interface Props { creditsRemaining?: number; showAskOwnerTooltip?: boolean; maxCharacterLength?: number; + suggestions?: WorkflowSuggestion[]; } const emit = defineEmits<{ @@ -181,10 +183,24 @@ const showPlaceholder = computed(() => { return !props.messages?.length && !props.loadingMessage && !props.sessionId; }); +const showSuggestions = computed(() => { + return showPlaceholder.value && props.suggestions && props.suggestions.length > 0; +}); + +const showBottomInput = computed(() => { + // Hide bottom input when showing suggestions (blank state with suggestions) + return !showSuggestions.value; +}); + function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) { return event?.type === 'event' && event?.eventName === 'end-session'; } +function onSuggestionClick(suggestion: WorkflowSuggestion) { + // Send the suggestion prompt as a message + emit('message', suggestion.prompt); +} + function onQuickReply(opt: ChatUI.QuickReply) { emit('message', opt.text, opt.type, opt.isFeedback); } @@ -436,7 +452,34 @@ defineExpose({ :class="$style.placeholder" data-test-id="placeholder-message" > -
+
+ + + +
+