Skip to content

Commit f63829f

Browse files
feat (ai/ui): add allowEmptySubmit flag to handleSubmit (vercel#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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 33 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 12 additions & 7 deletions
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

Lines changed: 97 additions & 1 deletion
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

Lines changed: 14 additions & 11 deletions
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

Lines changed: 101 additions & 1 deletion
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
});

0 commit comments

Comments
 (0)