mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(editor): Disable chat during interactive agent choices (#30111)
This commit is contained in:
parent
523fd85e45
commit
8171cf0b32
|
|
@ -94,7 +94,10 @@ export const askQuestionInputSchema = z.object({
|
|||
});
|
||||
|
||||
export const askQuestionResumeSchema = z.object({
|
||||
values: z.array(z.string()).min(1),
|
||||
values: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe('Selected option values, or freeform text entered in the Other field.'),
|
||||
});
|
||||
|
||||
export type AskQuestionOption = z.infer<typeof askQuestionOptionSchema>;
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ When: the user must choose a model/credential because the request is ambiguous,
|
|||
resolve_llm returned an ambiguous/missing credential result, or the user asks
|
||||
to pick/change/use a different model. Call AT MOST ONCE per build turn unless
|
||||
the user changes their mind.
|
||||
Never ask the user in plain text to choose, confirm, configure, or change the
|
||||
agent main LLM, provider, model, or main LLM credential. If the user needs to
|
||||
make that choice, call ask_llm so the picker card is shown.
|
||||
Returns: { provider, model, credentialId, credentialName }.
|
||||
After: set \`model = "{provider}/{model}"\` and \`credential = credentialName\`
|
||||
via write_config or patch_config.
|
||||
|
|
@ -172,8 +175,9 @@ When: you would otherwise ask a clarifying question whose answer is one (or
|
|||
more) of a known list. Examples: pick a Slack channel from a list,
|
||||
read-only vs read-write, which workflow to wrap.
|
||||
Inputs: \`question\`, \`options[{label,value,description?}]\`, \`allowMultiple?\`.
|
||||
Returns: { values: string[] }. Do NOT call ask_question for free-text input;
|
||||
ask in prose for that.
|
||||
Returns: { values: string[] }. Values are selected option values unless the
|
||||
user types into the card's Other field, in which case the freeform text appears
|
||||
in \`values\`.
|
||||
|
||||
### Rules
|
||||
- Never call two interactive tools in parallel. The run suspends on the first.
|
||||
|
|
@ -223,6 +227,8 @@ list_credentials. Pick the action by reason:
|
|||
Rules:
|
||||
- Explicit provider/model request → resolve_llm first, not ask_llm.
|
||||
- User asks to pick/change/use a different model → ask_llm.
|
||||
- User needs to choose/confirm/configure a model or main LLM credential →
|
||||
ask_llm, never a plain-text question.
|
||||
- No provider specified and resolve_llm reports ambiguity → ask_llm.`;
|
||||
|
||||
export const N8N_EXPRESSIONS_SECTION = `\
|
||||
|
|
@ -425,6 +431,8 @@ export const WORKFLOW_SECTION = `\
|
|||
resolve_llm reports ambiguity, or the user asks to choose/change/use a
|
||||
different model, call ask_llm. Then call read_config and write_config
|
||||
with the chosen \`model\` and \`credential\` plus a draft \`instructions\`.
|
||||
Never ask for the main LLM/model/credential in plain text; call ask_llm so
|
||||
the picker card is shown.
|
||||
2. Use ask_question whenever you have a clarifying question with discrete
|
||||
options (e.g. "Which Slack channel?" → list channels, "Read-only or
|
||||
read-write?"). Never put the question in plain text if options are known.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
|
|||
}
|
||||
|
||||
describe('ask_llm tool', () => {
|
||||
it('instructs the builder to render the picker instead of asking in prose', () => {
|
||||
const tool = buildAskLlmTool();
|
||||
expect(tool.systemInstruction).toContain('Never ask the user in plain text');
|
||||
expect(tool.systemInstruction).toContain('call ask_llm');
|
||||
});
|
||||
|
||||
it('suspends on first invocation so the user can choose', async () => {
|
||||
const tool = buildAskLlmTool();
|
||||
const ctx = makeCtx();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export function buildAskLlmTool(): BuiltTool {
|
|||
'After resume: set model = "{provider}/{model}" and credential = credentialName ' +
|
||||
'via write_config or patch_config.',
|
||||
)
|
||||
.systemInstruction(
|
||||
'Never ask the user in plain text to choose, confirm, configure, or change the agent ' +
|
||||
'main LLM, provider, model, or main LLM credential. If the user needs to make that ' +
|
||||
'choice, call ask_llm so the picker card is shown.',
|
||||
)
|
||||
.input(askLlmInputSchema)
|
||||
.suspend(askLlmInputSchema)
|
||||
.resume(askLlmResumeSchema)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ export function buildAskQuestionTool(): BuiltTool {
|
|||
.description(
|
||||
'Show a multiple-choice card in the chat UI and suspend until the user picks an ' +
|
||||
'answer. Use when the request is ambiguous and the answer is one (or more) of a ' +
|
||||
'known list of options. Do NOT use for free-text input — ask in prose for that. ' +
|
||||
'Returns { values: string[] } with the selected values.',
|
||||
'known list of options. The UI also includes an Other field, so returned values ' +
|
||||
'may include user-entered freeform text when the listed options are incomplete. ' +
|
||||
'Returns { values: string[] } with selected option values and/or Other text.',
|
||||
)
|
||||
.input(askQuestionInputSchema)
|
||||
.suspend(askQuestionInputSchema)
|
||||
|
|
|
|||
|
|
@ -5741,6 +5741,8 @@
|
|||
"agents.chat.loadHistory.error": "Could not load chat history",
|
||||
"agents.chat.clearHistory.error": "Could not clear chat history",
|
||||
"agents.chat.clearHistory": "Clear chat history",
|
||||
"agents.chat.input.placeholder": "Type a message...",
|
||||
"agents.chat.answerQuestionPlaceholder": "Answer the question above to continue",
|
||||
"agents.chat.misconfigured.title": "This agent isn't ready to run yet",
|
||||
"agents.chat.misconfigured.missingPrefix": "Missing:",
|
||||
"agents.chat.misconfigured.missing.instructions": "Instructions",
|
||||
|
|
@ -5751,6 +5753,9 @@
|
|||
"agents.chat.misconfigured.openBuild": "Finish setup in Build",
|
||||
"agents.chat.misconfigured.dismiss": "Dismiss",
|
||||
"agents.chat.askCredential.skip": "Skip",
|
||||
"agents.chat.askQuestion.otherLabel": "Other",
|
||||
"agents.chat.askQuestion.otherPlaceholder": "Type another answer",
|
||||
"agents.chat.askQuestion.submit": "Submit",
|
||||
"agents.list.published": "Published",
|
||||
"agents.list.noDescription": "No description",
|
||||
"agents.list.updatedAt": "Updated {date}",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_QUESTION_TOOL_NAME,
|
||||
type InteractiveToolName,
|
||||
} from '@n8n/api-types';
|
||||
import type { ChatMessage } from '../composables/agentChatMessages';
|
||||
import AgentChatPanel from '../components/AgentChatPanel.vue';
|
||||
|
||||
const sendMessageMock = vi.fn();
|
||||
const stopGeneratingMock = vi.fn();
|
||||
const loadHistoryMock = vi.fn();
|
||||
const messagesMock = ref<ChatMessage[]>([]);
|
||||
const isStreamingMock = ref(false);
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
|
|
@ -36,9 +46,9 @@ vi.mock('../components/AgentChatMessageList.vue', () => ({
|
|||
|
||||
vi.mock('../composables/useAgentChatStream', () => ({
|
||||
useAgentChatStream: () => ({
|
||||
messages: ref([]),
|
||||
isStreaming: ref(false),
|
||||
messagingState: computed(() => 'idle'),
|
||||
messages: messagesMock,
|
||||
isStreaming: isStreamingMock,
|
||||
messagingState: computed(() => (isStreamingMock.value ? 'receiving' : 'idle')),
|
||||
fatalError: ref(null),
|
||||
loadHistory: loadHistoryMock,
|
||||
sendMessage: sendMessageMock,
|
||||
|
|
@ -67,8 +77,65 @@ vi.mock('../composables/agentTelemetry.utils', () => ({
|
|||
describe('AgentChatPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
messagesMock.value = [];
|
||||
isStreamingMock.value = false;
|
||||
});
|
||||
|
||||
function mountPanel() {
|
||||
return mount(AgentChatPanel, {
|
||||
props: {
|
||||
projectId: 'p1',
|
||||
agentId: 'a1',
|
||||
endpoint: 'build',
|
||||
agentConfig: {
|
||||
name: 'Agent',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructions: 'Help.',
|
||||
},
|
||||
agentStatus: 'draft',
|
||||
connectedTriggers: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openInteractiveMessage(
|
||||
toolName: InteractiveToolName = ASK_QUESTION_TOOL_NAME,
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
status: 'awaitingUser',
|
||||
interactive:
|
||||
toolName === ASK_QUESTION_TOOL_NAME
|
||||
? {
|
||||
toolName: ASK_QUESTION_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Slack', value: 'slack' }],
|
||||
},
|
||||
}
|
||||
: toolName === ASK_LLM_TOOL_NAME
|
||||
? {
|
||||
toolName: ASK_LLM_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: { purpose: 'Choose a model' },
|
||||
}
|
||||
: {
|
||||
toolName: ASK_CREDENTIAL_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
purpose: 'Choose Slack credentials',
|
||||
credentialType: 'slackApi',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('awaits beforeSend before sending a build message', async () => {
|
||||
const events: string[] = [];
|
||||
let resolveBeforeSend: () => void = () => {};
|
||||
|
|
@ -85,7 +152,6 @@ describe('AgentChatPanel', () => {
|
|||
events.push('sendMessage');
|
||||
});
|
||||
|
||||
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
|
||||
const wrapper = mount(AgentChatPanel, {
|
||||
props: {
|
||||
projectId: 'p1',
|
||||
|
|
@ -120,7 +186,6 @@ describe('AgentChatPanel', () => {
|
|||
it('does not consume an initial message when beforeSend fails', async () => {
|
||||
const beforeSend = vi.fn().mockRejectedValue(new Error('flush failed'));
|
||||
|
||||
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
|
||||
const wrapper = mount(AgentChatPanel, {
|
||||
props: {
|
||||
projectId: 'p1',
|
||||
|
|
@ -144,4 +209,60 @@ describe('AgentChatPanel', () => {
|
|||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('disables chat and blocks sending while an interactive question is unresolved', async () => {
|
||||
messagesMock.value = [openInteractiveMessage()];
|
||||
|
||||
const wrapper = mountPanel();
|
||||
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
|
||||
expect(chatInput.props('disabled')).toBe(true);
|
||||
expect(chatInput.props('canSubmit')).toBe(false);
|
||||
expect(chatInput.props('placeholder')).toBe('agents.chat.answerQuestionPlaceholder');
|
||||
|
||||
(
|
||||
wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void }
|
||||
).sendMessageFromOutside('answer through chat');
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps chat enabled when the interactive card is resolved', () => {
|
||||
messagesMock.value = [
|
||||
{
|
||||
...openInteractiveMessage(),
|
||||
status: 'success',
|
||||
interactive: {
|
||||
toolName: ASK_QUESTION_TOOL_NAME,
|
||||
toolCallId: 'tc-1',
|
||||
resolvedAt: 1,
|
||||
input: {
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Slack', value: 'slack' }],
|
||||
},
|
||||
resolvedValue: { values: ['slack'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mountPanel();
|
||||
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
|
||||
expect(chatInput.props('disabled')).toBe(false);
|
||||
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
|
||||
});
|
||||
|
||||
it.each([ASK_LLM_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])(
|
||||
'disables chat while %s is unresolved',
|
||||
(toolName) => {
|
||||
messagesMock.value = [openInteractiveMessage(toolName)];
|
||||
|
||||
const wrapper = mountPanel();
|
||||
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
|
||||
|
||||
expect(chatInput.props('disabled')).toBe(true);
|
||||
expect(chatInput.props('canSubmit')).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ function mountCard(props = {}) {
|
|||
template:
|
||||
'<button type="button" data-testid="n8n-checkbox" :aria-checked="String(modelValue)" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)"></button>',
|
||||
},
|
||||
N8nInput: {
|
||||
props: ['modelValue', 'disabled', 'placeholder'],
|
||||
template:
|
||||
'<input :value="modelValue" :disabled="disabled" :placeholder="placeholder" v-bind="$attrs" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
|
||||
},
|
||||
N8nInputLabel: {
|
||||
props: ['label'],
|
||||
template: '<label><span>{{ label }}</span><slot /></label>',
|
||||
},
|
||||
N8nText: { template: '<p><slot/></p>' },
|
||||
},
|
||||
},
|
||||
|
|
@ -43,6 +52,7 @@ describe('AskQuestionCard', () => {
|
|||
expect(wrapper.text()).toContain('Option A');
|
||||
expect(wrapper.text()).toContain('Option B');
|
||||
expect(wrapper.text()).toContain('Extra info');
|
||||
expect(wrapper.find('[data-testid="ask-question-other-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits submit with selected value after clicking a single-choice option', async () => {
|
||||
|
|
@ -86,9 +96,20 @@ describe('AskQuestionCard', () => {
|
|||
const wrapper = mountCard({ disabled: true });
|
||||
const buttons = wrapper.findAll('button[aria-pressed]');
|
||||
await buttons[0].trigger('click');
|
||||
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Different option');
|
||||
await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click');
|
||||
expect(wrapper.emitted('submit')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('submits typed Other text in single-choice mode', async () => {
|
||||
const wrapper = mountCard();
|
||||
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Microsoft Teams');
|
||||
await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click');
|
||||
|
||||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect(emitted[0][0]).toEqual({ values: ['Use Microsoft Teams'] });
|
||||
});
|
||||
|
||||
it('allows selecting multiple values when allowMultiple=true', async () => {
|
||||
const wrapper = mountCard({ allowMultiple: true });
|
||||
const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]');
|
||||
|
|
@ -102,4 +123,24 @@ describe('AskQuestionCard', () => {
|
|||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect(emitted[0][0]).toEqual({ values: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('submits selected multiple values plus typed Other text', async () => {
|
||||
const wrapper = mountCard({ allowMultiple: true });
|
||||
const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]');
|
||||
await checkboxes[0].trigger('click');
|
||||
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Discord too');
|
||||
await wrapper.find('[data-testid="ask-question-submit"]').trigger('click');
|
||||
|
||||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect(emitted[0][0]).toEqual({ values: ['a', 'Use Discord too'] });
|
||||
});
|
||||
|
||||
it('allows typed Other text as the only multiple-choice value', async () => {
|
||||
const wrapper = mountCard({ allowMultiple: true });
|
||||
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Linear');
|
||||
await wrapper.find('[data-testid="ask-question-submit"]').trigger('click');
|
||||
|
||||
const emitted = wrapper.emitted('submit') as unknown[][];
|
||||
expect(emitted[0][0]).toEqual({ values: ['Use Linear'] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,6 +94,16 @@ const missingFields = computed(() => {
|
|||
return fatalError.value.missing.map(humaniseMissingField).join(', ');
|
||||
});
|
||||
|
||||
const hasOpenInteractiveQuestion = computed(() =>
|
||||
messages.value.some((message) => message.interactive && !message.interactive.resolvedAt),
|
||||
);
|
||||
|
||||
const chatPlaceholder = computed(() =>
|
||||
hasOpenInteractiveQuestion.value
|
||||
? locale.baseText('agents.chat.answerQuestionPlaceholder')
|
||||
: locale.baseText('agents.chat.input.placeholder'),
|
||||
);
|
||||
|
||||
function onOpenBuild() {
|
||||
dismissFatalError();
|
||||
emit('open-build');
|
||||
|
|
@ -103,7 +113,9 @@ watch(isStreaming, (v) => emit('update:streaming', v));
|
|||
|
||||
async function onSubmit() {
|
||||
const text = inputText.value.trim();
|
||||
if (!text || isStreaming.value || isPreparingToSend.value) return;
|
||||
if (!text || isStreaming.value || isPreparingToSend.value || hasOpenInteractiveQuestion.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPreparingToSend.value = true;
|
||||
try {
|
||||
|
|
@ -135,6 +147,7 @@ async function onSubmit() {
|
|||
}
|
||||
|
||||
function sendMessageFromOutside(message: string) {
|
||||
if (hasOpenInteractiveQuestion.value) return;
|
||||
inputText.value = message;
|
||||
void onSubmit();
|
||||
}
|
||||
|
|
@ -244,10 +257,19 @@ onBeforeUnmount(() => {
|
|||
<slot name="above-input" />
|
||||
<ChatInputBase
|
||||
v-model="inputText"
|
||||
placeholder="Type a message..."
|
||||
:placeholder="chatPlaceholder"
|
||||
:is-streaming="messagingState === 'receiving'"
|
||||
:can-submit="!isStreaming && !isPreparingToSend && inputText.trim().length > 0"
|
||||
:disabled="isPreparingToSend || (isStreaming && messagingState !== 'receiving')"
|
||||
:can-submit="
|
||||
!hasOpenInteractiveQuestion &&
|
||||
!isStreaming &&
|
||||
!isPreparingToSend &&
|
||||
inputText.trim().length > 0
|
||||
"
|
||||
:disabled="
|
||||
hasOpenInteractiveQuestion ||
|
||||
isPreparingToSend ||
|
||||
(isStreaming && messagingState !== 'receiving')
|
||||
"
|
||||
data-testid="chat-input"
|
||||
@submit="onSubmit"
|
||||
@stop="stopGenerating"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||
import { N8nButton, N8nCard, N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCard,
|
||||
N8nCheckbox,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AskQuestionResume } from '@n8n/api-types';
|
||||
|
||||
interface Option {
|
||||
|
|
@ -22,7 +31,9 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const SINGLE_CHOICE_SUBMIT_DELAY_MS = 250;
|
||||
const i18n = useI18n();
|
||||
const selected = ref<string[]>([]);
|
||||
const otherText = ref('');
|
||||
let singleChoiceSubmitTimer: number | undefined;
|
||||
|
||||
/** Labels of the persisted selected values, for the resolved state. */
|
||||
|
|
@ -33,6 +44,13 @@ const resolvedLabels = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const trimmedOtherText = computed(() => otherText.value.trim());
|
||||
const selectedValuesWithOther = computed(() => {
|
||||
const values = [...selected.value];
|
||||
if (trimmedOtherText.value) values.push(trimmedOtherText.value);
|
||||
return values;
|
||||
});
|
||||
|
||||
function selectSingle(value: string) {
|
||||
if (props.disabled) return;
|
||||
selected.value = [value];
|
||||
|
|
@ -61,8 +79,25 @@ function toggleMultiple(value: string, checked: boolean) {
|
|||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (selected.value.length === 0 || props.disabled) return;
|
||||
emit('submit', { values: [...selected.value] });
|
||||
const values = selectedValuesWithOther.value;
|
||||
if (values.length === 0 || props.disabled) return;
|
||||
emit('submit', { values });
|
||||
}
|
||||
|
||||
function submitOther() {
|
||||
if (!trimmedOtherText.value || props.disabled) return;
|
||||
clearSingleChoiceSubmitTimer();
|
||||
emit('submit', { values: [trimmedOtherText.value] });
|
||||
}
|
||||
|
||||
function onOtherKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
if (props.allowMultiple) {
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
submitOther();
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearSingleChoiceSubmitTimer);
|
||||
|
|
@ -129,14 +164,43 @@ onBeforeUnmount(clearSingleChoiceSubmitTimer);
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="ask-question-other-input"
|
||||
:label="i18n.baseText('agents.chat.askQuestion.otherLabel')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
:class="$style.other"
|
||||
>
|
||||
<div :class="$style.otherInputRow">
|
||||
<N8nInput
|
||||
id="ask-question-other-input"
|
||||
v-model="otherText"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
:placeholder="i18n.baseText('agents.chat.askQuestion.otherPlaceholder')"
|
||||
data-testid="ask-question-other-input"
|
||||
@keydown="onOtherKeydown"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="!allowMultiple"
|
||||
:disabled="!trimmedOtherText || disabled"
|
||||
size="small"
|
||||
data-testid="ask-question-other-submit"
|
||||
@click="submitOther"
|
||||
>
|
||||
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</N8nInputLabel>
|
||||
|
||||
<div v-if="allowMultiple" :class="$style.actions">
|
||||
<N8nButton
|
||||
:disabled="selected.length === 0 || disabled"
|
||||
:disabled="selectedValuesWithOther.length === 0 || disabled"
|
||||
size="medium"
|
||||
data-testid="ask-question-submit"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Submit
|
||||
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -183,6 +247,24 @@ onBeforeUnmount(clearSingleChoiceSubmitTimer);
|
|||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.other {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
padding-top: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.otherInputRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
|
||||
:global(.n8n-input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
@include questionOptions.option-button-row;
|
||||
@include questionOptions.active-selected;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user