feat: Add message history trimming to AI workflow builder (no-changelog) (#17829)

This commit is contained in:
Eugene
2025-08-05 15:16:12 +02:00
committed by GitHub
parent dd049249be
commit bac61a7e0d
10 changed files with 421 additions and 3 deletions

View File

@@ -0,0 +1,3 @@
export const MAX_AI_BUILDER_PROMPT_LENGTH = 1000; // characters
export const MAX_USER_MESSAGES = 10; // Maximum number of user messages to keep in the state

View File

@@ -0,0 +1,154 @@
import { HumanMessage, AIMessage as AssistantMessage, ToolMessage } from '@langchain/core/messages';
import type { BaseMessage } from '@langchain/core/messages';
import { createTrimMessagesReducer } from '../workflow-state';
describe('createTrimMessagesReducer', () => {
it('should return messages unchanged when human messages are within limit', () => {
const reducer = createTrimMessagesReducer(3);
const messages: BaseMessage[] = [
new HumanMessage('User 1'),
new AssistantMessage('Assistant 1'),
new ToolMessage({ content: 'Tool 1', tool_call_id: '1' }),
new ToolMessage({ content: 'Tool 2', tool_call_id: '2' }),
new AssistantMessage('Assistant 2'),
];
const result = reducer(messages);
expect(result).toEqual(messages);
expect(result.length).toBe(5);
});
it('should trim messages when human messages exceed limit', () => {
const reducer = createTrimMessagesReducer(3);
const messages: BaseMessage[] = [
new HumanMessage('User 1'),
new AssistantMessage('Assistant 1'),
new ToolMessage({ content: 'Tool 1', tool_call_id: '1' }),
new HumanMessage('User 2'),
new AssistantMessage('Assistant 2'),
new HumanMessage('User 3'),
new AssistantMessage('Assistant 3'),
new HumanMessage('User 4'),
new AssistantMessage('Assistant 4'),
];
const result = reducer(messages);
// Should keep only the last 3 HumanMessages
const humanMessages = result.filter((msg) => msg instanceof HumanMessage);
expect(humanMessages.length).toBe(3);
// Should start with HumanMessage
expect(result[0]).toBeInstanceOf(HumanMessage);
expect((result[0] as HumanMessage).content).toBe('User 2');
// Should preserve messages between HumanMessages
expect(result.length).toBe(6); // User 2, Assistant 2, User 3, Assistant 3, User 4, Assistant 4
});
it('should handle typical conversation pattern', () => {
const reducer = createTrimMessagesReducer(2);
const messages: BaseMessage[] = [
new HumanMessage('User 1'),
new AssistantMessage('Assistant 1'),
new ToolMessage({ content: 'Tool 1', tool_call_id: '1' }),
new ToolMessage({ content: 'Tool 2', tool_call_id: '2' }),
new AssistantMessage('Assistant 2'),
new HumanMessage('User 2'),
new AssistantMessage('Assistant 3'),
new ToolMessage({ content: 'Tool 3', tool_call_id: '3' }),
new ToolMessage({ content: 'Tool 4', tool_call_id: '4' }),
new AssistantMessage('Assistant 4'),
new HumanMessage('User 3'),
new AssistantMessage('Assistant 5'),
new ToolMessage({ content: 'Tool 5', tool_call_id: '5' }),
new ToolMessage({ content: 'Tool 6', tool_call_id: '6' }),
new AssistantMessage('Assistant 6'),
];
const result = reducer(messages);
// Should keep only the last 2 HumanMessages
const humanMessages = result.filter((msg) => msg instanceof HumanMessage);
expect(humanMessages.length).toBe(2);
// Should start with HumanMessage
expect(result[0]).toBeInstanceOf(HumanMessage);
expect((result[0] as HumanMessage).content).toBe('User 2');
// Should include all messages from User 2 onwards
expect(result.length).toBe(10);
expect(result.map((m) => m.content)).toEqual([
'User 2',
'Assistant 3',
'Tool 3',
'Tool 4',
'Assistant 4',
'User 3',
'Assistant 5',
'Tool 5',
'Tool 6',
'Assistant 6',
]);
});
it('should handle edge case with exactly maxUserMessages', () => {
const reducer = createTrimMessagesReducer(2);
const messages: BaseMessage[] = [
new HumanMessage('User 1'),
new AssistantMessage('Assistant 1'),
new HumanMessage('User 2'),
new AssistantMessage('Assistant 2'),
];
const result = reducer(messages);
expect(result).toEqual(messages);
expect(result.length).toBe(4);
});
it('should handle empty array', () => {
const reducer = createTrimMessagesReducer(5);
const messages: BaseMessage[] = [];
const result = reducer(messages);
expect(result).toEqual([]);
});
it('should handle array with no HumanMessages', () => {
const reducer = createTrimMessagesReducer(5);
const messages: BaseMessage[] = [
new AssistantMessage('Assistant 1'),
new ToolMessage({ content: 'Tool 1', tool_call_id: '1' }),
new AssistantMessage('Assistant 2'),
];
const result = reducer(messages);
expect(result).toEqual(messages);
});
it('should handle maxUserMessages = 1', () => {
const reducer = createTrimMessagesReducer(1);
const messages: BaseMessage[] = [
new HumanMessage('User 1'),
new AssistantMessage('Assistant 1'),
new HumanMessage('User 2'),
new AssistantMessage('Assistant 2'),
new HumanMessage('User 3'),
new AssistantMessage('Assistant 3'),
];
const result = reducer(messages);
// Should keep only the last HumanMessage
const humanMessages = result.filter((msg) => msg instanceof HumanMessage);
expect(humanMessages.length).toBe(1);
// Should start with User 3
expect(result[0]).toBeInstanceOf(HumanMessage);
expect((result[0] as HumanMessage).content).toBe('User 3');
// Should only include User 3 and Assistant 3
expect(result.length).toBe(2);
});
});

View File

@@ -12,8 +12,10 @@ import type {
NodeExecutionSchema, NodeExecutionSchema,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { conversationCompactChain } from './chains/conversation-compact'; import { conversationCompactChain } from './chains/conversation-compact';
import { LLMServiceError } from './errors'; import { LLMServiceError, ValidationError } from './errors';
import { createAddNodeTool } from './tools/add-node.tool'; import { createAddNodeTool } from './tools/add-node.tool';
import { createConnectNodesTool } from './tools/connect-nodes.tool'; import { createConnectNodesTool } from './tools/connect-nodes.tool';
import { createNodeDetailsTool } from './tools/node-details.tool'; import { createNodeDetailsTool } from './tools/node-details.tool';
@@ -191,6 +193,17 @@ export class WorkflowBuilderAgent {
} }
async *chat(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) { async *chat(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) {
// Check for the message maximum length
if (payload.message.length > MAX_AI_BUILDER_PROMPT_LENGTH) {
this.logger?.warn('Message exceeds maximum length', {
messageLength: payload.message.length,
maxLength: MAX_AI_BUILDER_PROMPT_LENGTH,
});
throw new ValidationError(
`Message exceeds maximum length of ${MAX_AI_BUILDER_PROMPT_LENGTH} characters`,
);
}
const agent = this.createWorkflow().compile({ checkpointer: this.checkpointer }); const agent = this.createWorkflow().compile({ checkpointer: this.checkpointer });
const workflowId = payload.workflowContext?.currentWorkflow?.id; const workflowId = payload.workflowContext?.currentWorkflow?.id;
// Generate thread ID from workflowId and userId // Generate thread ID from workflowId and userId

View File

@@ -1,5 +1,9 @@
import type { BaseMessage } from '@langchain/core/messages'; import type { BaseMessage } from '@langchain/core/messages';
import { HumanMessage } from '@langchain/core/messages';
import { Annotation, messagesStateReducer } from '@langchain/langgraph'; import { Annotation, messagesStateReducer } from '@langchain/langgraph';
import type { BinaryOperator } from '@langchain/langgraph/dist/channels/binop';
import { MAX_USER_MESSAGES } from '@/constants';
import type { SimpleWorkflow, WorkflowOperation } from './types/workflow'; import type { SimpleWorkflow, WorkflowOperation } from './types/workflow';
import type { ChatPayload } from './workflow-builder-agent'; import type { ChatPayload } from './workflow-builder-agent';
@@ -32,9 +36,44 @@ function operationsReducer(
return [...(current ?? []), ...update]; return [...(current ?? []), ...update];
} }
// Creates a reducer that trims the message history to keep only the last `maxUserMessages` HumanMessage instances
export function createTrimMessagesReducer(maxUserMessages: number) {
return (current: BaseMessage[]): BaseMessage[] => {
// Count HumanMessage instances and remember their indices
const humanMessageIndices: number[] = [];
current.forEach((msg, index) => {
if (msg instanceof HumanMessage) {
humanMessageIndices.push(index);
}
});
// If we have fewer than or equal to maxUserMessages, return as is
if (humanMessageIndices.length <= maxUserMessages) {
return current;
}
// Find the index of the first HumanMessage that we want to keep
const startHumanMessageIndex =
humanMessageIndices[humanMessageIndices.length - maxUserMessages];
// Slice from that HumanMessage onwards
return current.slice(startHumanMessageIndex);
};
}
// Utility function to combine multiple message reducers into one.
function combineMessageReducers(...reducers: Array<BinaryOperator<BaseMessage[], BaseMessage[]>>) {
return (current: BaseMessage[], update: BaseMessage[]): BaseMessage[] => {
return reducers.reduce((acc, reducer) => reducer(acc, update), current);
};
}
export const WorkflowState = Annotation.Root({ export const WorkflowState = Annotation.Root({
messages: Annotation<BaseMessage[]>({ messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer, reducer: combineMessageReducers(
messagesStateReducer,
createTrimMessagesReducer(MAX_USER_MESSAGES),
),
default: () => [], default: () => [],
}), }),
// // The original prompt from the user. // // The original prompt from the user.

View File

@@ -236,4 +236,23 @@ describe('AskAssistantChat', () => {
expect(wrapper.container).toMatchSnapshot(); expect(wrapper.container).toMatchSnapshot();
expect(wrapper.queryByTestId('error-retry-button')).not.toBeInTheDocument(); expect(wrapper.queryByTestId('error-retry-button')).not.toBeInTheDocument();
}); });
it('limits maximum input length when maxLength prop is specified', async () => {
const wrapper = render(AskAssistantChat, {
global: {
directives: {
n8nHtml,
},
stubs,
},
props: {
user: { firstName: 'Kobi', lastName: 'Dog' },
maxLength: 100,
},
});
expect(wrapper.container).toMatchSnapshot();
const textarea = wrapper.queryByTestId('chat-input');
expect(textarea).toHaveAttribute('maxLength', '100');
});
}); });

View File

@@ -28,6 +28,7 @@ interface Props {
placeholder?: string; placeholder?: string;
scrollOnNewMessage?: boolean; scrollOnNewMessage?: boolean;
showStop?: boolean; showStop?: boolean;
maxLength?: number;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -249,6 +250,7 @@ watch(
:placeholder="placeholder ?? t('assistantChat.inputPlaceholder')" :placeholder="placeholder ?? t('assistantChat.inputPlaceholder')"
rows="1" rows="1"
wrap="hard" wrap="hard"
:maxlength="maxLength"
data-test-id="chat-input" data-test-id="chat-input"
@keydown.enter.exact.prevent="onSendMessage" @keydown.enter.exact.prevent="onSendMessage"
@input.prevent="growInput" @input.prevent="growInput"

View File

@@ -193,6 +193,192 @@ exports[`AskAssistantChat > does not render retry button if no error is present
</div> </div>
`; `;
exports[`AskAssistantChat > limits maximum input length when maxLength prop is specified 1`] = `
<div>
<div
class="container"
>
<div
class="header"
>
<div
class="chatTitle"
>
<div
class="headerText"
>
<svg
fill="none"
height="18"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
fill="url(#paint0_linear_173_12825)"
/>
<defs>
<lineargradient
gradientUnits="userSpaceOnUse"
id="paint0_linear_173_12825"
x1="-3.67094e-07"
x2="28.8315"
y1="-0.000120994"
y2="9.82667"
>
<stop
stop-color="var(--color-assistant-highlight-1)"
/>
<stop
offset="0.495"
stop-color="var(--color-assistant-highlight-2)"
/>
<stop
offset="1"
stop-color="var(--color-assistant-highlight-3)"
/>
</lineargradient>
</defs>
</svg>
<span
class="text large"
>
AI Assistant
</span>
</div>
</div>
<div
class="back"
data-test-id="close-chat-button"
>
<n8n-icon-stub
color="text-base"
icon="arrow-right"
spin="false"
/>
</div>
</div>
<div
class="body"
>
<div
class="placeholder"
data-test-id="placeholder-message"
>
<div
class="greeting"
>
Hi Kobi 👋
</div>
<div
class="info"
>
<p>
I can answer most questions about building workflows in n8n.
</p>
<p>
For specific tasks, youll see the
<button
class="button"
data-test-id="ask-assistant-button"
style="height: 18px;"
tabindex="-1"
>
<div>
<div
style="padding: 0px 6px;"
>
<svg
class="icon"
fill="none"
height="10"
viewBox="0 0 24 24"
width="10"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
fill="url(#paint0_linear_173_12825)"
/>
<defs>
<lineargradient
gradientUnits="userSpaceOnUse"
id="paint0_linear_173_12825"
x1="-3.67094e-07"
x2="28.8315"
y1="-0.000120994"
y2="9.82667"
>
<stop
stop-color="var(--color-assistant-highlight-1)"
/>
<stop
offset="0.495"
stop-color="var(--color-assistant-highlight-2)"
/>
<stop
offset="1"
stop-color="var(--color-assistant-highlight-3)"
/>
</lineargradient>
</defs>
</svg>
<span
class="text small"
>
Ask Assistant
</span>
</div>
</div>
</button>
button in the UI.
</p>
<p>
How can I help?
</p>
</div>
</div>
</div>
<div
class="inputWrapper"
data-test-id="chat-input-wrapper"
>
<textarea
class="ignore-key-press-node-creator ignore-key-press-canvas"
data-test-id="chat-input"
maxlength="100"
placeholder="Enter your response..."
rows="1"
wrap="hard"
/>
<n8n-button-stub
active="false"
block="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
element="button"
icon="send"
label=""
loading="false"
outline="false"
size="large"
square="true"
text="true"
type="primary"
/>
</div>
</div>
</div>
`;
exports[`AskAssistantChat > renders chat with messages correctly 1`] = ` exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
<div> <div>
<div <div

View File

@@ -135,6 +135,7 @@ watch(currentRoute, () => {
:show-stop="true" :show-stop="true"
:scroll-on-new-message="true" :scroll-on-new-message="true"
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')" :placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:max-length="1000"
@close="emit('close')" @close="emit('close')"
@message="onUserMessage" @message="onUserMessage"
@feedback="onFeedback" @feedback="onFeedback"

View File

@@ -127,6 +127,7 @@ function onAddNodeClick() {
:placeholder="i18n.baseText('aiAssistant.builder.placeholder')" :placeholder="i18n.baseText('aiAssistant.builder.placeholder')"
:read-only="false" :read-only="false"
:rows="15" :rows="15"
:maxlength="1000"
@focus="isFocused = true" @focus="isFocused = true"
@blur="isFocused = false" @blur="isFocused = false"
@keydown.meta.enter.stop="onSubmit" @keydown.meta.enter.stop="onSubmit"

View File

@@ -9,7 +9,7 @@ exports[`CanvasNodeAIPrompt > should render component correctly 1`] = `
<form class="form"> <form class="form">
<div class="el-textarea el-input--large n8n-input formTextarea formTextarea"> <div class="el-textarea el-input--large n8n-input formTextarea formTextarea">
<!-- input --> <!-- input -->
<!-- textarea --><textarea class="el-textarea__inner" name="aiBuilderPrompt" rows="15" title="" read-only="false" tabindex="0" autocomplete="off" placeholder="Ask n8n to build..."></textarea> <!-- textarea --><textarea class="el-textarea__inner" name="aiBuilderPrompt" rows="15" title="" maxlength="1000" read-only="false" tabindex="0" autocomplete="off" placeholder="Ask n8n to build..."></textarea>
<!--v-if--> <!--v-if-->
</div> </div>
<footer class="formFooter"><button class="button button primary medium disabled" disabled="" aria-disabled="true" aria-live="polite" type="submit"> <footer class="formFooter"><button class="button button primary medium disabled" disabled="" aria-disabled="true" aria-live="polite" type="submit">