Skip to content

Commit

Permalink
feat (ai/ui): add allowEmptySubmit flag to handleSubmit (vercel#2346)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremyphilemon authored Jul 21, 2024
1 parent 3a53af9 commit f63829f
Show file tree
Hide file tree
Showing 12 changed files with 391 additions and 32 deletions.
9 changes: 9 additions & 0 deletions .changeset/popular-lions-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@ai-sdk/ui-utils': patch
'@ai-sdk/svelte': patch
'@ai-sdk/react': patch
'@ai-sdk/solid': patch
'@ai-sdk/vue': patch
---

feat (ai/ui): add allowEmptySubmit flag to handleSubmit
33 changes: 33 additions & 0 deletions content/docs/05-ai-sdk-ui/02-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,39 @@ export async function POST(req: Request) {
}
```

## Empty Submissions

You can configure the `useChat` hook to allow empty submissions by setting the `allowEmptySubmit` option to `true`.

```tsx filename="app/page.tsx" highlight="18"
'use client';

import { useChat } from 'ai/react';

export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div>
{messages.map(m => (
<div key={m.id}>
{m.role}: {m.content}
</div>
))}

<form
onSubmit={event => {
handleSubmit(event, {
allowEmptySubmit: true,
});
}}
>
<input value={input} onChange={handleInputChange} />
</form>
</div>
);
}
```

## Attachments (Experimental)

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.
Expand Down
7 changes: 7 additions & 0 deletions content/docs/07-reference/ai-sdk-ui/01-use-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,13 @@ Allows you to easily create a conversational user interface for your chatbot app
type: 'JSONValue',
description: 'Additional data to be sent to the API endpoint.',
},
{
name: 'allowEmptySubmit',
type: 'boolean',
isOptional: true,
description:
'A boolean that determines whether to allow submitting an empty input that triggers a generation. Defaults to `false`.',
},
{
name: 'experimental_attachments',
type: 'FileList | Array<Attachment>',
Expand Down
19 changes: 12 additions & 7 deletions packages/react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,15 +529,17 @@ By default, it's set to 0, which will disable the feature.
options: ChatRequestOptions = {},
metadata?: Object,
) => {
event?.preventDefault?.();

if (!input && !options.allowEmptySubmit) return;

if (metadata) {
extraMetadataRef.current = {
...extraMetadataRef.current,
...metadata,
};
}

event?.preventDefault?.();

const attachmentsForRequest: Attachment[] = [];
const attachmentsFromOptions = options.experimental_attachments;

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

const chatRequest: ChatRequest = {
messages: input
? messagesRef.current.concat({
const messages =
!input && options.allowEmptySubmit
? messagesRef.current
: messagesRef.current.concat({
id: generateId(),
createdAt: new Date(),
role: 'user',
Expand All @@ -592,8 +595,10 @@ By default, it's set to 0, which will disable the feature.
attachmentsForRequest.length > 0
? attachmentsForRequest
: undefined,
})
: messagesRef.current,
});

const chatRequest: ChatRequest = {
messages,
options: requestOptions,
headers: requestOptions.headers,
body: requestOptions.body,
Expand Down
98 changes: 97 additions & 1 deletion packages/react/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,106 @@ describe('form actions', () => {
const secondInput = screen.getByTestId('do-input');
await userEvent.type(secondInput, '{Enter}');

await screen.findByTestId('message-2');
expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
},
),
);
});

describe('form actions (with options)', () => {
const TestComponent = () => {
const { messages, handleSubmit, handleInputChange, isLoading, input } =
useChat({ streamMode: 'text' });

return (
<div>
{messages.map((m, idx) => (
<div data-testid={`message-${idx}`} key={m.id}>
{m.role === 'user' ? 'User: ' : 'AI: '}
{m.content}
</div>
))}

<form
onSubmit={event => {
handleSubmit(event, {
allowEmptySubmit: true,
});
}}
>
<input
value={input}
placeholder="Send message..."
onChange={handleInputChange}
disabled={isLoading}
data-testid="do-input"
/>
</form>
</div>
);
};

beforeEach(() => {
render(<TestComponent />);
});

afterEach(() => {
vi.restoreAllMocks();
cleanup();
});

it(
'allowEmptySubmit',
withTestServer(
[
{
url: '/api/chat',
type: 'stream-values',
content: ['Hello', ',', ' world', '.'],
},
{
url: '/api/chat',
type: 'stream-values',
content: ['How', ' can', ' I', ' help', ' you', '?'],
},
{
url: '/api/chat',
type: 'stream-values',
content: ['The', ' sky', ' is', ' blue', '.'],
},
],
async () => {
const firstInput = screen.getByTestId('do-input');
await userEvent.type(firstInput, 'hi');
await userEvent.keyboard('{Enter}');

await screen.findByTestId('message-0');
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');

await screen.findByTestId('message-1');
expect(screen.getByTestId('message-1')).toHaveTextContent(
'AI: Hello, world.',
);

const secondInput = screen.getByTestId('do-input');
await userEvent.type(secondInput, '{Enter}');

expect(screen.getByTestId('message-2')).toHaveTextContent(
'AI: How can I help you?',
);

const thirdInput = screen.getByTestId('do-input');
await userEvent.type(thirdInput, 'what color is the sky?');
await userEvent.type(thirdInput, '{Enter}');

expect(screen.getByTestId('message-3')).toHaveTextContent(
'User: what color is the sky?',
);

await screen.findByTestId('message-4');
expect(screen.getByTestId('message-4')).toHaveTextContent(
'AI: The sky is blue.',
);
},
),
);
Expand Down
25 changes: 14 additions & 11 deletions packages/solid/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,30 +384,33 @@ export function useChat(
options = {},
metadata?: Object,
) => {
event?.preventDefault?.();
const inputValue = input();

if (!inputValue && !options.allowEmptySubmit) return;

if (metadata) {
extraMetadata = {
...extraMetadata,
...metadata,
};
}

event?.preventDefault?.();
const inputValue = input();

const requestOptions = {
headers: options.headers ?? options.options?.headers,
body: options.body ?? options.options?.body,
};

const chatRequest: ChatRequest = {
messages: inputValue
? messagesRef.concat({
id: generateId()(),
role: 'user',
content: inputValue,
createdAt: new Date(),
})
: messagesRef,
messages:
!inputValue && options.allowEmptySubmit
? messagesRef
: messagesRef.concat({
id: generateId()(),
role: 'user',
content: inputValue,
createdAt: new Date(),
}),
options: requestOptions,
body: requestOptions.body,
headers: requestOptions.headers,
Expand Down
102 changes: 101 additions & 1 deletion packages/solid/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,110 @@ describe('form actions', () => {
await userEvent.click(input);
await userEvent.keyboard('{Enter}');

// Wait for the second AI response to complete
expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
});
});

describe('form actions (with options)', () => {
const TestComponent = () => {
const { messages, handleSubmit, handleInputChange, isLoading, input } =
useChat();

return (
<div>
<For each={messages()}>
{(m, idx) => (
<div data-testid={`message-${idx()}`}>
{m.role === 'user' ? 'User: ' : 'AI: '}
{m.content}
</div>
)}
</For>

<form
onSubmit={event => {
handleSubmit(event, {
allowEmptySubmit: true,
});
}}
>
<input
value={input()}
placeholder="Send message..."
onInput={handleInputChange}
disabled={isLoading()}
data-testid="do-input"
/>
</form>
</div>
);
};

beforeEach(() => {
render(() => <TestComponent />);
});

afterEach(() => {
vi.restoreAllMocks();
cleanup();
});

it('allowEmptySubmit', async () => {
mockFetchDataStream({
url: 'https://example.com/api/chat',
chunks: ['Hello', ',', ' world', '.'].map(token =>
formatStreamPart('text', token),
),
});

const input = screen.getByTestId('do-input');
await userEvent.type(input, 'hi');
await userEvent.keyboard('{Enter}');
expect(input).toHaveValue('');

// Wait for the user message to appear
await screen.findByTestId('message-0');
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');

// Wait for the AI response to complete
await screen.findByTestId('message-1');
expect(screen.getByTestId('message-1')).toHaveTextContent(
'AI: Hello, world.',
);

mockFetchDataStream({
url: 'https://example.com/api/chat',
chunks: ['How', ' can', ' I', ' help', ' you', '?'].map(token =>
formatStreamPart('text', token),
),
});

await userEvent.click(input);
await userEvent.keyboard('{Enter}');

await screen.findByTestId('message-2');
expect(screen.getByTestId('message-2')).toHaveTextContent(
'AI: How can I help you?',
);

mockFetchDataStream({
url: 'https://example.com/api/chat',
chunks: ['The', ' sky', ' is', ' blue.'].map(token =>
formatStreamPart('text', token),
),
});

await userEvent.type(input, 'what color is the sky?');
await userEvent.keyboard('{Enter}');

await screen.findByTestId('message-3');
expect(screen.getByTestId('message-3')).toHaveTextContent(
'User: what color is the sky?',
);

await screen.findByTestId('message-4');
expect(screen.getByTestId('message-4')).toHaveTextContent(
'AI: The sky is blue.',
);
});
});
Loading

0 comments on commit f63829f

Please sign in to comment.