Skip to content

Commit f63829f

Browse files
authoredJul 21, 2024··
feat (ai/ui): add allowEmptySubmit flag to handleSubmit (#2346)
1 parent 3a53af9 commit f63829f

File tree

12 files changed

+391
-32
lines changed

12 files changed

+391
-32
lines changed
 

‎.changeset/popular-lions-brake.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ai-sdk/ui-utils': patch
3+
'@ai-sdk/svelte': patch
4+
'@ai-sdk/react': patch
5+
'@ai-sdk/solid': patch
6+
'@ai-sdk/vue': patch
7+
---
8+
9+
feat (ai/ui): add allowEmptySubmit flag to handleSubmit

‎content/docs/05-ai-sdk-ui/02-chatbot.mdx

+33
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,39 @@ export async function POST(req: Request) {
350350
}
351351
```
352352

353+
## Empty Submissions
354+
355+
You can configure the `useChat` hook to allow empty submissions by setting the `allowEmptySubmit` option to `true`.
356+
357+
```tsx filename="app/page.tsx" highlight="18"
358+
'use client';
359+
360+
import { useChat } from 'ai/react';
361+
362+
export default function Chat() {
363+
const { messages, input, handleInputChange, handleSubmit } = useChat();
364+
return (
365+
<div>
366+
{messages.map(m => (
367+
<div key={m.id}>
368+
{m.role}: {m.content}
369+
</div>
370+
))}
371+
372+
<form
373+
onSubmit={event => {
374+
handleSubmit(event, {
375+
allowEmptySubmit: true,
376+
});
377+
}}
378+
>
379+
<input value={input} onChange={handleInputChange} />
380+
</form>
381+
</div>
382+
);
383+
}
384+
```
385+
353386
## Attachments (Experimental)
354387

355388
The `useChat` hook supports sending attachments along with a message as well as rendering them on the client. This can be useful for building applications that involve sending images, files, or other media content to the AI provider.

‎content/docs/07-reference/ai-sdk-ui/01-use-chat.mdx

+7
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,13 @@ Allows you to easily create a conversational user interface for your chatbot app
434434
type: 'JSONValue',
435435
description: 'Additional data to be sent to the API endpoint.',
436436
},
437+
{
438+
name: 'allowEmptySubmit',
439+
type: 'boolean',
440+
isOptional: true,
441+
description:
442+
'A boolean that determines whether to allow submitting an empty input that triggers a generation. Defaults to `false`.',
443+
},
437444
{
438445
name: 'experimental_attachments',
439446
type: 'FileList | Array<Attachment>',

‎packages/react/src/use-chat.ts

+12-7
Original file line numberDiff line numberDiff line change
@@ -529,15 +529,17 @@ By default, it's set to 0, which will disable the feature.
529529
options: ChatRequestOptions = {},
530530
metadata?: Object,
531531
) => {
532+
event?.preventDefault?.();
533+
534+
if (!input && !options.allowEmptySubmit) return;
535+
532536
if (metadata) {
533537
extraMetadataRef.current = {
534538
...extraMetadataRef.current,
535539
...metadata,
536540
};
537541
}
538542

539-
event?.preventDefault?.();
540-
541543
const attachmentsForRequest: Attachment[] = [];
542544
const attachmentsFromOptions = options.experimental_attachments;
543545

@@ -581,9 +583,10 @@ By default, it's set to 0, which will disable the feature.
581583
body: options.body ?? options.options?.body,
582584
};
583585

584-
const chatRequest: ChatRequest = {
585-
messages: input
586-
? messagesRef.current.concat({
586+
const messages =
587+
!input && options.allowEmptySubmit
588+
? messagesRef.current
589+
: messagesRef.current.concat({
587590
id: generateId(),
588591
createdAt: new Date(),
589592
role: 'user',
@@ -592,8 +595,10 @@ By default, it's set to 0, which will disable the feature.
592595
attachmentsForRequest.length > 0
593596
? attachmentsForRequest
594597
: undefined,
595-
})
596-
: messagesRef.current,
598+
});
599+
600+
const chatRequest: ChatRequest = {
601+
messages,
597602
options: requestOptions,
598603
headers: requestOptions.headers,
599604
body: requestOptions.body,

‎packages/react/src/use-chat.ui.test.tsx

+97-1
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,106 @@ describe('form actions', () => {
298298
const secondInput = screen.getByTestId('do-input');
299299
await userEvent.type(secondInput, '{Enter}');
300300

301-
await screen.findByTestId('message-2');
301+
expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
302+
},
303+
),
304+
);
305+
});
306+
307+
describe('form actions (with options)', () => {
308+
const TestComponent = () => {
309+
const { messages, handleSubmit, handleInputChange, isLoading, input } =
310+
useChat({ streamMode: 'text' });
311+
312+
return (
313+
<div>
314+
{messages.map((m, idx) => (
315+
<div data-testid={`message-${idx}`} key={m.id}>
316+
{m.role === 'user' ? 'User: ' : 'AI: '}
317+
{m.content}
318+
</div>
319+
))}
320+
321+
<form
322+
onSubmit={event => {
323+
handleSubmit(event, {
324+
allowEmptySubmit: true,
325+
});
326+
}}
327+
>
328+
<input
329+
value={input}
330+
placeholder="Send message..."
331+
onChange={handleInputChange}
332+
disabled={isLoading}
333+
data-testid="do-input"
334+
/>
335+
</form>
336+
</div>
337+
);
338+
};
339+
340+
beforeEach(() => {
341+
render(<TestComponent />);
342+
});
343+
344+
afterEach(() => {
345+
vi.restoreAllMocks();
346+
cleanup();
347+
});
348+
349+
it(
350+
'allowEmptySubmit',
351+
withTestServer(
352+
[
353+
{
354+
url: '/api/chat',
355+
type: 'stream-values',
356+
content: ['Hello', ',', ' world', '.'],
357+
},
358+
{
359+
url: '/api/chat',
360+
type: 'stream-values',
361+
content: ['How', ' can', ' I', ' help', ' you', '?'],
362+
},
363+
{
364+
url: '/api/chat',
365+
type: 'stream-values',
366+
content: ['The', ' sky', ' is', ' blue', '.'],
367+
},
368+
],
369+
async () => {
370+
const firstInput = screen.getByTestId('do-input');
371+
await userEvent.type(firstInput, 'hi');
372+
await userEvent.keyboard('{Enter}');
373+
374+
await screen.findByTestId('message-0');
375+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
376+
377+
await screen.findByTestId('message-1');
378+
expect(screen.getByTestId('message-1')).toHaveTextContent(
379+
'AI: Hello, world.',
380+
);
381+
382+
const secondInput = screen.getByTestId('do-input');
383+
await userEvent.type(secondInput, '{Enter}');
384+
302385
expect(screen.getByTestId('message-2')).toHaveTextContent(
303386
'AI: How can I help you?',
304387
);
388+
389+
const thirdInput = screen.getByTestId('do-input');
390+
await userEvent.type(thirdInput, 'what color is the sky?');
391+
await userEvent.type(thirdInput, '{Enter}');
392+
393+
expect(screen.getByTestId('message-3')).toHaveTextContent(
394+
'User: what color is the sky?',
395+
);
396+
397+
await screen.findByTestId('message-4');
398+
expect(screen.getByTestId('message-4')).toHaveTextContent(
399+
'AI: The sky is blue.',
400+
);
305401
},
306402
),
307403
);

‎packages/solid/src/use-chat.ts

+14-11
Original file line numberDiff line numberDiff line change
@@ -384,30 +384,33 @@ export function useChat(
384384
options = {},
385385
metadata?: Object,
386386
) => {
387+
event?.preventDefault?.();
388+
const inputValue = input();
389+
390+
if (!inputValue && !options.allowEmptySubmit) return;
391+
387392
if (metadata) {
388393
extraMetadata = {
389394
...extraMetadata,
390395
...metadata,
391396
};
392397
}
393398

394-
event?.preventDefault?.();
395-
const inputValue = input();
396-
397399
const requestOptions = {
398400
headers: options.headers ?? options.options?.headers,
399401
body: options.body ?? options.options?.body,
400402
};
401403

402404
const chatRequest: ChatRequest = {
403-
messages: inputValue
404-
? messagesRef.concat({
405-
id: generateId()(),
406-
role: 'user',
407-
content: inputValue,
408-
createdAt: new Date(),
409-
})
410-
: messagesRef,
405+
messages:
406+
!inputValue && options.allowEmptySubmit
407+
? messagesRef
408+
: messagesRef.concat({
409+
id: generateId()(),
410+
role: 'user',
411+
content: inputValue,
412+
createdAt: new Date(),
413+
}),
411414
options: requestOptions,
412415
body: requestOptions.body,
413416
headers: requestOptions.headers,

‎packages/solid/src/use-chat.ui.test.tsx

+101-1
Original file line numberDiff line numberDiff line change
@@ -491,10 +491,110 @@ describe('form actions', () => {
491491
await userEvent.click(input);
492492
await userEvent.keyboard('{Enter}');
493493

494-
// Wait for the second AI response to complete
494+
expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
495+
});
496+
});
497+
498+
describe('form actions (with options)', () => {
499+
const TestComponent = () => {
500+
const { messages, handleSubmit, handleInputChange, isLoading, input } =
501+
useChat();
502+
503+
return (
504+
<div>
505+
<For each={messages()}>
506+
{(m, idx) => (
507+
<div data-testid={`message-${idx()}`}>
508+
{m.role === 'user' ? 'User: ' : 'AI: '}
509+
{m.content}
510+
</div>
511+
)}
512+
</For>
513+
514+
<form
515+
onSubmit={event => {
516+
handleSubmit(event, {
517+
allowEmptySubmit: true,
518+
});
519+
}}
520+
>
521+
<input
522+
value={input()}
523+
placeholder="Send message..."
524+
onInput={handleInputChange}
525+
disabled={isLoading()}
526+
data-testid="do-input"
527+
/>
528+
</form>
529+
</div>
530+
);
531+
};
532+
533+
beforeEach(() => {
534+
render(() => <TestComponent />);
535+
});
536+
537+
afterEach(() => {
538+
vi.restoreAllMocks();
539+
cleanup();
540+
});
541+
542+
it('allowEmptySubmit', async () => {
543+
mockFetchDataStream({
544+
url: 'https://example.com/api/chat',
545+
chunks: ['Hello', ',', ' world', '.'].map(token =>
546+
formatStreamPart('text', token),
547+
),
548+
});
549+
550+
const input = screen.getByTestId('do-input');
551+
await userEvent.type(input, 'hi');
552+
await userEvent.keyboard('{Enter}');
553+
expect(input).toHaveValue('');
554+
555+
// Wait for the user message to appear
556+
await screen.findByTestId('message-0');
557+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
558+
559+
// Wait for the AI response to complete
560+
await screen.findByTestId('message-1');
561+
expect(screen.getByTestId('message-1')).toHaveTextContent(
562+
'AI: Hello, world.',
563+
);
564+
565+
mockFetchDataStream({
566+
url: 'https://example.com/api/chat',
567+
chunks: ['How', ' can', ' I', ' help', ' you', '?'].map(token =>
568+
formatStreamPart('text', token),
569+
),
570+
});
571+
572+
await userEvent.click(input);
573+
await userEvent.keyboard('{Enter}');
574+
495575
await screen.findByTestId('message-2');
496576
expect(screen.getByTestId('message-2')).toHaveTextContent(
497577
'AI: How can I help you?',
498578
);
579+
580+
mockFetchDataStream({
581+
url: 'https://example.com/api/chat',
582+
chunks: ['The', ' sky', ' is', ' blue.'].map(token =>
583+
formatStreamPart('text', token),
584+
),
585+
});
586+
587+
await userEvent.type(input, 'what color is the sky?');
588+
await userEvent.keyboard('{Enter}');
589+
590+
await screen.findByTestId('message-3');
591+
expect(screen.getByTestId('message-3')).toHaveTextContent(
592+
'User: what color is the sky?',
593+
);
594+
595+
await screen.findByTestId('message-4');
596+
expect(screen.getByTestId('message-4')).toHaveTextContent(
597+
'AI: The sky is blue.',
598+
);
499599
});
500600
});

‎packages/svelte/src/use-chat.ts

+11-8
Original file line numberDiff line numberDiff line change
@@ -378,20 +378,23 @@ export function useChat({
378378
event?.preventDefault?.();
379379
const inputValue = get(input);
380380

381+
if (!inputValue && !options.allowEmptySubmit) return;
382+
381383
const requestOptions = {
382384
headers: options.headers ?? options.options?.headers,
383385
body: options.body ?? options.options?.body,
384386
};
385387

386388
const chatRequest: ChatRequest = {
387-
messages: inputValue
388-
? get(messages).concat({
389-
id: generateId(),
390-
content: inputValue,
391-
role: 'user',
392-
createdAt: new Date(),
393-
} as Message)
394-
: get(messages),
389+
messages:
390+
!inputValue && options.allowEmptySubmit
391+
? get(messages)
392+
: get(messages).concat({
393+
id: generateId(),
394+
content: inputValue,
395+
role: 'user',
396+
createdAt: new Date(),
397+
} as Message),
395398
options: requestOptions,
396399
body: requestOptions.body,
397400
headers: requestOptions.headers,

‎packages/ui-utils/src/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ Additional data to be sent to the API endpoint.
324324
*/
325325
experimental_attachments?: FileList | Array<Attachment>;
326326

327+
/**
328+
* Allow submitting an empty message. Defaults to `false`.
329+
*/
330+
allowEmptySubmit?: boolean;
331+
327332
/**
328333
The options to be passed to the fetch call.
329334
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import { useChat } from './use-chat';
3+
4+
const { messages, handleSubmit, input } = useChat();
5+
</script>
6+
7+
<template>
8+
<div class="flex flex-col w-full max-w-md py-24 mx-auto stretch">
9+
<div
10+
v-for="(m, idx) in messages"
11+
key="m.id"
12+
:data-testid="`message-${idx}`"
13+
>
14+
{{ m.role === 'user' ? 'User: ' : 'AI: ' }}
15+
{{ m.content }}
16+
</div>
17+
18+
<form @submit.prevent="(event) => handleSubmit(event, {
19+
allowEmptySubmit: true,
20+
})">
21+
<input
22+
:data-testid="`do-input`"
23+
v-model="input"
24+
type="text"
25+
placeholder="Type a message..."
26+
/>
27+
</form>
28+
29+
</div>
30+
</template>

‎packages/vue/src/use-chat.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,17 @@ export function useChat({
281281

282282
const inputValue = input.value;
283283

284+
if (!inputValue && !options.allowEmptySubmit) return;
285+
284286
triggerRequest(
285-
inputValue
286-
? messages.value.concat({
287+
!inputValue && options.allowEmptySubmit
288+
? messages.value
289+
: messages.value.concat({
287290
id: generateId(),
288291
createdAt: new Date(),
289292
content: inputValue,
290293
role: 'user',
291-
})
292-
: messages.value,
294+
}),
293295
options,
294296
);
295297

‎packages/vue/src/use-chat.ui.test.tsx

+66
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TestChatComponent from './TestChatComponent.vue';
1010
import TestChatTextStreamComponent from './TestChatTextStreamComponent.vue';
1111
import TestChatFormComponent from './TestChatFormComponent.vue';
1212
import { formatStreamPart } from '@ai-sdk/ui-utils';
13+
import TestChatFormComponentOptions from './TestChatFormComponentOptions.vue';
1314

1415
describe('stream data stream', () => {
1516
beforeEach(() => {
@@ -143,6 +144,50 @@ describe('form actions', () => {
143144
cleanup();
144145
});
145146

147+
it('should show streamed response using handleSubmit', async () => {
148+
mockFetchDataStream({
149+
url: 'https://example.com/api/chat',
150+
chunks: ['Hello', ',', ' world', '.'].map(token =>
151+
formatStreamPart('text', token),
152+
),
153+
});
154+
155+
const firstInput = screen.getByTestId('do-input');
156+
await userEvent.type(firstInput, 'hi');
157+
await userEvent.keyboard('{Enter}');
158+
159+
await screen.findByTestId('message-0');
160+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
161+
162+
await screen.findByTestId('message-1');
163+
expect(screen.getByTestId('message-1')).toHaveTextContent(
164+
'AI: Hello, world.',
165+
);
166+
167+
mockFetchDataStream({
168+
url: 'https://example.com/api/chat',
169+
chunks: ['How', ' can', ' I', ' help', ' you', '?'].map(token =>
170+
formatStreamPart('text', token),
171+
),
172+
});
173+
174+
const secondInput = screen.getByTestId('do-input');
175+
await userEvent.type(secondInput, '{Enter}');
176+
177+
expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
178+
});
179+
});
180+
181+
describe('form actions (with options)', () => {
182+
beforeEach(() => {
183+
render(TestChatFormComponentOptions);
184+
});
185+
186+
afterEach(() => {
187+
vi.restoreAllMocks();
188+
cleanup();
189+
});
190+
146191
it('should show streamed response using handleSubmit', async () => {
147192
mockFetchDataStream({
148193
url: 'https://example.com/api/chat',
@@ -177,5 +222,26 @@ describe('form actions', () => {
177222
expect(screen.getByTestId('message-2')).toHaveTextContent(
178223
'AI: How can I help you?',
179224
);
225+
226+
mockFetchDataStream({
227+
url: 'https://example.com/api/chat',
228+
chunks: ['The', ' sky', ' is', ' blue.'].map(token =>
229+
formatStreamPart('text', token),
230+
),
231+
});
232+
233+
const thirdInput = screen.getByTestId('do-input');
234+
await userEvent.type(thirdInput, 'what color is the sky?');
235+
await userEvent.keyboard('{Enter}');
236+
237+
await screen.findByTestId('message-3');
238+
expect(screen.getByTestId('message-3')).toHaveTextContent(
239+
'User: what color is the sky?',
240+
);
241+
242+
await screen.findByTestId('message-4');
243+
expect(screen.getByTestId('message-4')).toHaveTextContent(
244+
'AI: The sky is blue.',
245+
);
180246
});
181247
});

0 commit comments

Comments
 (0)
Please sign in to comment.