feat: Add planning step to AI workflow builder (no-changelog) (#18737)

Co-authored-by: Eugene Molodkin <eugene@n8n.io>
This commit is contained in:
oleg
2025-09-01 16:28:19 +02:00
committed by GitHub
parent caeaa679c6
commit 94f0048f02
31 changed files with 2977 additions and 1281 deletions

View File

@@ -24,6 +24,7 @@ interface Props {
};
messages?: ChatUI.AssistantMessage[];
streaming?: boolean;
disabled?: boolean;
loadingMessage?: string;
sessionId?: string;
title?: string;
@@ -66,7 +67,9 @@ function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.Assistan
// filter out these messages so that tool collapsing works correctly
function filterOutHiddenMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
return messages.filter((message) => Boolean(getSupportedMessageComponent(message.type)));
return messages.filter(
(message) => Boolean(getSupportedMessageComponent(message.type)) || message.type === 'custom',
);
}
function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
@@ -165,7 +168,7 @@ const sessionEnded = computed(() => {
});
const sendDisabled = computed(() => {
return !textInputValue.value || props.streaming || sessionEnded.value;
return !textInputValue.value || props.streaming || sessionEnded.value || props.disabled;
});
const showPlaceholder = computed(() => {
@@ -226,6 +229,13 @@ watch(
},
{ immediate: true, deep: true },
);
// Expose focusInput method to parent components
defineExpose({
focusInput: () => {
chatInput.value?.focus();
},
});
</script>
<template>
@@ -265,7 +275,11 @@ watch(
@code-replace="() => emit('codeReplace', i)"
@code-undo="() => emit('codeUndo', i)"
@feedback="onRateMessage"
/>
>
<template v-if="$slots['custom-message']" #custom-message="customMessageProps">
<slot name="custom-message" v-bind="customMessageProps" />
</template>
</MessageWrapper>
<div
v-if="lastMessageQuickReplies.length && i === normalizedMessages.length - 1"
@@ -336,8 +350,8 @@ watch(
ref="chatInput"
v-model="textInputValue"
class="ignore-key-press-node-creator ignore-key-press-canvas"
:class="{ [$style.disabled]: sessionEnded || streaming }"
:disabled="sessionEnded || streaming"
:class="{ [$style.disabled]: sessionEnded || streaming || disabled }"
:disabled="sessionEnded || streaming || disabled"
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
rows="1"
wrap="hard"

View File

@@ -31,16 +31,27 @@ const messageComponent = computed<Component | null>(() => {
</script>
<template>
<component
:is="messageComponent"
v-if="messageComponent"
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:streaming="streaming"
:is-last-message="isLastMessage"
@code-replace="emit('codeReplace')"
@code-undo="emit('codeUndo')"
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
/>
<div>
<component
:is="messageComponent"
v-if="messageComponent"
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:streaming="streaming"
:is-last-message="isLastMessage"
@code-replace="emit('codeReplace')"
@code-undo="emit('codeUndo')"
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
/>
<slot
v-else-if="message.type === 'custom'"
name="custom-message"
:message="message"
:is-first-of-role="isFirstOfRole"
:user="user"
:streaming="streaming"
:is-last-message="isLastMessage"
/>
</div>
</template>

View File

@@ -23,6 +23,7 @@ export function getSupportedMessageComponent(type: ChatUI.AssistantMessage['type
return ToolMessage;
case 'agent-suggestion':
case 'workflow-updated':
case 'custom':
return null;
default:
return null;

View File

@@ -87,6 +87,15 @@ export namespace ChatUI {
}>;
}
export interface CustomMessage {
id?: string;
role: 'assistant' | 'user';
type: 'custom';
message?: string;
customType: string;
data: unknown;
}
type MessagesWithReplies = (
| TextMessage
| CodeDiffMessage
@@ -106,6 +115,7 @@ export namespace ChatUI {
| AgentSuggestionMessage
| WorkflowUpdatedMessage
| ToolMessage
| CustomMessage
) & {
id?: string;
read?: boolean;
@@ -186,6 +196,12 @@ export function isToolMessage(
return msg.type === 'tool';
}
export function isCustomMessage(
msg: ChatUI.AssistantMessage,
): msg is ChatUI.CustomMessage & { id?: string; read?: boolean } {
return msg.type === 'custom';
}
// Helper to ensure message has required id and read properties
export function hasRequiredProps<T extends ChatUI.AssistantMessage>(
msg: T,

View File

@@ -199,6 +199,10 @@
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
"aiAssistant.builder.plan.intro": "Do you want to proceed with this plan?",
"aiAssistant.builder.plan.approve": "Approve Plan",
"aiAssistant.builder.plan.reject": "Request Changes",
"aiAssistant.builder.plan.whatToChange": "What would you like to change?",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",

View File

@@ -26,6 +26,7 @@ import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeUi } from '@/Interface';
import { PLAN_APPROVAL_MESSAGE } from '@/constants';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
@@ -976,4 +977,382 @@ describe('AskAssistantBuild', () => {
expect(builderStore.initialGeneration).toBe(false);
});
});
describe('NodesPlan message handling', () => {
it('should render plan messages with appropriate controls', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
const regularMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'text' as const,
content: 'Regular message',
};
// First test with regular message - should not show plan controls
builderStore.$patch({
chatMessages: [regularMessage],
});
const { queryByText } = renderComponent();
await flushPromises();
// Regular message should not have plan controls
expect(queryByText('aiAssistant.builder.plan.approve')).not.toBeInTheDocument();
// Now test with plan message
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
await flushPromises();
// Plan message should have controls
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
});
it('should handle plan approval correctly', async () => {
// Setup: empty workflow so initialGeneration is true
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { getByText } = renderComponent();
await flushPromises();
// Find and click the approve button
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Verify that sendChatMessage was called with approval message
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
text: PLAN_APPROVAL_MESSAGE,
initialGeneration: true,
});
});
it('should handle plan rejection correctly', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Here is my plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data from an API',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { getByText, queryByText } = renderComponent();
await flushPromises();
// Find and click the reject button
const rejectButton = getByText('aiAssistant.builder.plan.reject');
await fireEvent.click(rejectButton);
await flushPromises();
// Verify plan controls are hidden after rejection
expect(queryByText('aiAssistant.builder.plan.approve')).not.toBeInTheDocument();
expect(queryByText('aiAssistant.builder.plan.reject')).not.toBeInTheDocument();
});
it('should show plan controls only for the last plan message', async () => {
const planMessage1 = {
id: 'plan1',
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'First plan:',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'First plan',
},
],
};
const textMessage = {
id: 'text1',
role: 'assistant' as const,
type: 'text' as const,
content: 'Some text',
};
const planMessage2 = {
id: 'plan2',
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Second plan:',
data: [
{
nodeType: 'n8n-nodes-base.emailSend',
nodeName: 'Send Email',
reasoning: 'Second plan',
},
],
};
// First set just the first plan message
builderStore.$patch({
chatMessages: [planMessage1],
});
const { queryByText } = renderComponent();
await flushPromises();
// First plan should show controls when it's alone
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
// Now add more messages
builderStore.$patch({
chatMessages: [planMessage1, textMessage, planMessage2],
});
await flushPromises();
// Now only the last plan message should have visible controls
// We check this by seeing that approve button is still there (from plan2)
expect(queryByText('aiAssistant.builder.plan.approve')).toBeInTheDocument();
});
});
describe('Plan status management in workflow messages watcher', () => {
it('should disable chat when plan is pending and enable after action', async () => {
// Add a nodes plan message as the last message
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
// Also need to mock workflowMessages getter
builderStore.workflowMessages = [];
const { getByText, queryByRole } = renderComponent();
await flushPromises();
// Trigger the watcher by updating workflowMessages
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
// Chat should be disabled while plan is pending
const chatInput = queryByRole('textbox');
expect(chatInput).toHaveAttribute('disabled');
// Approve the plan
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Chat should be enabled after approval
const chatInputAfter = queryByRole('textbox');
expect(chatInputAfter).not.toHaveAttribute('disabled');
});
it('should not disable chat when last message is not a nodes plan', async () => {
// Add a regular text message as the last message
const textMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'text' as const,
content: 'Regular message',
};
builderStore.$patch({
chatMessages: [textMessage],
});
// Mock workflowMessages getter
builderStore.workflowMessages = [];
const { queryByRole } = renderComponent();
await flushPromises();
// Trigger the watcher
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
// Chat should remain enabled
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
});
describe('Disabled state when plan is pending', () => {
it('should disable chat input when plan status is pending', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
// Mock workflowMessages getter
builderStore.workflowMessages = [];
const { queryByRole } = renderComponent();
await flushPromises();
// Trigger workflow message update to set planStatus to pending
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: '{}',
},
];
await flushPromises();
const chatInput = queryByRole('textbox');
expect(chatInput).toHaveAttribute('disabled');
});
it('should enable chat input after plan approval', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { queryByRole, getByText } = renderComponent();
await flushPromises();
// Click approve button
const approveButton = getByText('aiAssistant.builder.plan.approve');
await fireEvent.click(approveButton);
await flushPromises();
// Chat should be enabled after approval
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
it('should enable chat input after plan rejection', async () => {
const nodesPlanMessage = {
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'custom' as const,
customType: 'nodesPlan',
message: 'Plan message',
data: [
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
reasoning: 'To fetch data',
},
],
};
builderStore.$patch({
chatMessages: [nodesPlanMessage],
});
const { queryByRole, getByText } = renderComponent();
await flushPromises();
// Click reject button
const rejectButton = getByText('aiAssistant.builder.plan.reject');
await fireEvent.click(rejectButton);
await flushPromises();
// Chat should be enabled after rejection
const chatInput = queryByRole('textbox');
expect(chatInput).not.toHaveAttribute('disabled');
});
});
});

View File

@@ -1,16 +1,19 @@
<script lang="ts" setup>
import { useBuilderStore } from '@/stores/builder.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, nextTick } from 'vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import type { ChatUI, RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
import type { NodesPlanMessageType } from './NodesPlanMessage.vue';
import NodesPlanMessage from './NodesPlanMessage.vue';
import { PLAN_APPROVAL_MESSAGE } from '@/constants';
const emit = defineEmits<{
close: [];
@@ -28,6 +31,8 @@ const workflowSaver = useWorkflowSaving({ router });
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set<string>());
const trackedTools = ref(new Set<string>());
const planStatus = ref<'pending' | 'approved' | 'rejected'>();
const assistantChatRef = ref<InstanceType<typeof AskAssistantChat> | null>(null);
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
@@ -51,6 +56,63 @@ async function onUserMessage(content: string) {
builderStore.sendChatMessage({ text: content, initialGeneration: isInitialGeneration });
}
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
}
function onFeedback(feedback: RatingFeedback) {
if (feedback.rating) {
telemetry.track('User rated workflow generation', {
helpful: feedback.rating === 'up',
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
if (feedback.feedback) {
telemetry.track('User submitted workflow generation feedback', {
feedback: feedback.feedback,
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
}
function isNodesPlanMessage(message: ChatUI.AssistantMessage): message is NodesPlanMessageType {
return (
message.type === 'custom' && message.customType === 'nodesPlan' && Array.isArray(message.data)
);
}
function onApprovePlan() {
planStatus.value = 'approved';
telemetry.track('User clicked Approve plan', {
session_id: builderStore.trackingSessionId,
});
void onUserMessage(PLAN_APPROVAL_MESSAGE);
}
function onRequestChanges() {
planStatus.value = 'rejected';
telemetry.track('User clicked Request changes', {
session_id: builderStore.trackingSessionId,
});
// Focus the input after rejecting the plan
void nextTick(() => {
assistantChatRef.value?.focusInput();
});
}
function shouldShowPlanControls(message: NodesPlanMessageType) {
const planMessageIndex = builderStore.chatMessages.findIndex((msg) => msg.id === message.id);
return planMessageIndex === builderStore.chatMessages.length - 1;
}
// Watch for workflow updates and apply them
watch(
() => builderStore.workflowMessages,
@@ -95,6 +157,12 @@ watch(
}
}
});
// Check if last message is a plan message and if so, whether to show controls
const lastMessage = builderStore.chatMessages[builderStore.chatMessages.length - 1];
if (lastMessage && isNodesPlanMessage(lastMessage)) {
planStatus.value = 'pending';
}
},
{ deep: true },
);
@@ -126,29 +194,6 @@ watch(
},
);
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
}
function onFeedback(feedback: RatingFeedback) {
if (feedback.rating) {
telemetry.track('User rated workflow generation', {
helpful: feedback.rating === 'up',
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
if (feedback.feedback) {
telemetry.track('User submitted workflow generation feedback', {
feedback: feedback.feedback,
workflow_id: workflowsStore.workflowId,
session_id: builderStore.trackingSessionId,
});
}
}
// Reset on route change
watch(currentRoute, () => {
onNewWorkflow();
@@ -158,9 +203,11 @@ watch(currentRoute, () => {
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
<AskAssistantChat
ref="assistantChatRef"
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
:disabled="planStatus === 'pending'"
:loading-message="loadingMessage"
:mode="i18n.baseText('aiAssistant.builder.mode')"
:title="'n8n AI'"
@@ -176,6 +223,16 @@ watch(currentRoute, () => {
<template #header>
<slot name="header" />
</template>
<template #custom-message="{ message, ...props }">
<NodesPlanMessage
v-if="message.customType === 'nodesPlan' && isNodesPlanMessage(message)"
:message="message"
:show-controls="shouldShowPlanControls(message)"
v-bind="props"
@approve-plan="onApprovePlan"
@request-changes="onRequestChanges"
/>
</template>
<template #placeholder>
<n8n-text :class="$style.topText">{{
i18n.baseText('aiAssistant.builder.placeholder')

View File

@@ -0,0 +1,134 @@
<!-- eslint-disable import-x/extensions -->
<script setup lang="ts">
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ChatUI, RatingFeedback } from '@n8n/design-system';
import BaseMessage from '@n8n/design-system/components/AskAssistantChat/messages/BaseMessage.vue';
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
interface NodesPlan {
nodeType: string;
nodeName: string;
reasoning: string;
}
export type NodesPlanMessageType = ChatUI.CustomMessage & {
data: NodesPlan[];
};
interface Props {
message: NodesPlanMessageType;
showControls?: boolean;
isFirstOfRole: boolean;
user?: {
firstName: string;
lastName: string;
};
streaming?: boolean;
isLastMessage?: boolean;
}
const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const showControlsLocal = ref(props.showControls ?? true);
const changesRequested = ref(false);
const emit = defineEmits<{
approvePlan: [];
requestChanges: [];
feedback: [RatingFeedback];
}>();
function onApprovePlan() {
showControlsLocal.value = false;
emit('approvePlan');
}
function onRequestChanges() {
showControlsLocal.value = false;
changesRequested.value = true;
emit('requestChanges');
}
</script>
<template>
<BaseMessage
:class="$style.message"
:message="message"
:is-first-of-role="true"
:user="user"
@feedback="(feedback) => emit('feedback', feedback)"
>
{{ message.message }}
<ol :class="$style.nodes">
<li v-for="(node, index) in message.data" :key="index" :class="$style.node">
<n8n-tooltip placement="left" :show-after="300">
<template #content>
{{ node.reasoning }}
</template>
<div :class="$style.node">
<NodeIcon
:class="$style.nodeIcon"
:node-type="nodeTypesStore.getNodeType(node.nodeType)"
:node-name="node.nodeName"
:show-tooltip="false"
:size="12"
/>
<span>{{ node.nodeName }}</span>
</div>
</n8n-tooltip>
</li>
</ol>
<template v-if="showControls && showControlsLocal && !streaming">
{{ i18n.baseText('aiAssistant.builder.plan.intro') }}
<div :class="$style.controls">
<n8n-button type="primary" @click="onApprovePlan">{{
i18n.baseText('aiAssistant.builder.plan.approve')
}}</n8n-button>
<n8n-button type="secondary" @click="onRequestChanges">{{
i18n.baseText('aiAssistant.builder.plan.reject')
}}</n8n-button>
</div>
</template>
<template v-if="changesRequested">
<span class="mb-m">{{ i18n.baseText('aiAssistant.builder.plan.whatToChange') }}</span>
</template>
</BaseMessage>
</template>
<style lang="scss" module>
.message {
display: flex;
flex-direction: column;
}
.nodes {
list-style: none;
padding: var(--spacing-2xs);
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.nodeIcon {
padding: var(--spacing-3xs);
border-radius: var(--border-radius-base);
border: 1px solid var(--color-foreground-base);
display: inline-flex;
}
.node {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
font-size: var(--font-size-2xs);
}
.controls {
display: flex;
gap: var(--spacing-2xs);
margin-top: var(--spacing-xs);
}
.followUpMessage {
margin-top: var(--spacing-m);
}
</style>

View File

@@ -1,7 +1,12 @@
import type { ChatUI } from '@n8n/design-system/types/assistant';
import type { ChatRequest } from '@/types/assistant.types';
import { useI18n } from '@n8n/i18n';
import { isTextMessage, isWorkflowUpdatedMessage, isToolMessage } from '@/types/assistant.types';
import {
isTextMessage,
isWorkflowUpdatedMessage,
isToolMessage,
isPlanMessage,
} from '@/types/assistant.types';
export interface MessageProcessingResult {
messages: ChatUI.AssistantMessage[];
@@ -119,6 +124,18 @@ export function useBuilderMessages() {
// Don't clear thinking for workflow updates - they're just state changes
} else if (isToolMessage(msg)) {
processToolMessage(messages, msg, messageId);
} else if (isPlanMessage(msg)) {
// Add new plan message
messages.push({
id: messageId,
role: 'assistant',
type: 'custom',
customType: 'nodesPlan',
message: msg.message,
data: msg.plan,
} satisfies ChatUI.CustomMessage);
// Plan messages are informational, clear thinking
shouldClearThinking = true;
} else if ('type' in msg && msg.type === 'error' && 'content' in msg) {
// Handle error messages from the API
// API sends error messages with type: 'error' and content field
@@ -145,7 +162,7 @@ export function useBuilderMessages() {
messageId: string,
): void {
// Use toolCallId as the message ID for consistency across updates
const toolMessageId = msg.toolCallId || messageId;
const toolMessageId = msg.toolCallId ?? messageId;
// Check if we already have this tool message
const existingIndex = msg.toolCallId
@@ -214,14 +231,15 @@ export function useBuilderMessages() {
}
}
// Check if there's any text message after the last completed tool
// Check if there's any text or custom message after the last completed tool
// Note: workflow-updated messages shouldn't count as they're just canvas state updates
let hasTextAfterTools = false;
// Custom messages (like plan messages) should count as responses
let hasResponseAfterTools = false;
if (lastCompletedToolIndex !== -1) {
for (let i = lastCompletedToolIndex + 1; i < messages.length; i++) {
const msg = messages[i];
if (msg.type === 'text') {
hasTextAfterTools = true;
if (msg.type === 'text' || msg.type === 'custom') {
hasResponseAfterTools = true;
break;
}
}
@@ -229,7 +247,7 @@ export function useBuilderMessages() {
return {
hasAnyRunningTools: false,
isStillThinking: hasCompletedTools && !hasTextAfterTools,
isStillThinking: hasCompletedTools && !hasResponseAfterTools,
};
}
@@ -372,6 +390,17 @@ export function useBuilderMessages() {
} satisfies ChatUI.AssistantMessage;
}
if (isPlanMessage(message)) {
return {
id,
role: 'assistant',
type: 'custom',
customType: 'nodesPlan',
data: message.plan,
message: message.message,
} satisfies ChatUI.CustomMessage;
}
// Handle event messages
if ('type' in message && message.type === 'event') {
return {

View File

@@ -886,6 +886,7 @@ export const ASK_AI_MAX_PROMPT_LENGTH = 600;
export const ASK_AI_MIN_PROMPT_LENGTH = 15;
export const ASK_AI_LOADING_DURATION_MS = 12000;
export const ASK_AI_SLIDE_OUT_DURATION_MS = 200;
export const PLAN_APPROVAL_MESSAGE = 'Proceed with the plan';
export const APPEND_ATTRIBUTION_DEFAULT_PATH = 'parameters.options.appendAttribution';

View File

@@ -94,6 +94,10 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const workflowMessages = computed(() => chatMessages.value.filter(isWorkflowUpdatedMessage));
const assistantMessages = computed(() =>
chatMessages.value.filter((msg) => msg.role === 'assistant'),
);
// Chat management functions
/**
* Resets the entire chat session to initial state.
@@ -439,6 +443,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
workflowPrompt,
toolMessages,
workflowMessages,
assistantMessages,
trackingSessionId,
streamingAbortController,
initialGeneration,

View File

@@ -166,6 +166,16 @@ export namespace ChatRequest {
}
// API-only types
export interface PlanMessage {
role: 'assistant';
type: 'plan';
plan: Array<{
nodeType: string;
nodeName: string;
reasoning: string;
}>;
message?: string; // For plan-review messages
}
export type MessageResponse =
| ((
@@ -177,6 +187,7 @@ export namespace ChatRequest {
| ChatUI.WorkflowUpdatedMessage
| ToolMessage
| ChatUI.ErrorMessage
| PlanMessage
) & {
quickReplies?: ChatUI.QuickReply[];
})
@@ -266,3 +277,7 @@ export function isEndSessionMessage(
): msg is ChatUI.EndSessionMessage {
return 'type' in msg && msg.type === 'event' && msg.eventName === 'end-session';
}
export function isPlanMessage(msg: ChatRequest.MessageResponse): msg is ChatRequest.PlanMessage {
return 'type' in msg && msg.type === 'plan' && 'plan' in msg && Array.isArray(msg.plan);
}

View File

@@ -1882,7 +1882,9 @@ watch(
};
fallbackNodes.value =
builderStore.isAIBuilderEnabled && builderStore.isAssistantEnabled
builderStore.isAIBuilderEnabled &&
builderStore.isAssistantEnabled &&
builderStore.assistantMessages.length === 0
? [aiPromptItem]
: [addNodesItem];
},