Skip to content

Commit 3db90c3

Browse files
authoredJul 5, 2024··
feat (ai/ui): allow empty handleSubmit submissions for useChat (#2164)
1 parent 580a5a3 commit 3db90c3

File tree

11 files changed

+318
-48
lines changed

11 files changed

+318
-48
lines changed
 

‎.changeset/five-lions-do.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'solidstart-openai': patch
3+
'@ai-sdk/svelte': patch
4+
'@ai-sdk/react': patch
5+
'@ai-sdk/solid': patch
6+
'ai': patch
7+
'@ai-sdk/vue': patch
8+
---
9+
10+
allow empty handleSubmit submissions for useChat

‎examples/solidstart-openai/src/routes/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default function Chat() {
2020
class="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
2121
value={input()}
2222
placeholder="Say something..."
23-
onChange={handleInputChange}
23+
onInput={handleInputChange}
2424
/>
2525
</form>
2626
</div>

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

+16-10
Original file line numberDiff line numberDiff line change
@@ -357,16 +357,22 @@ export function useChat({
357357
) => {
358358
event?.preventDefault?.();
359359
const inputValue = get(input);
360-
if (!inputValue) return;
361-
362-
append(
363-
{
364-
content: inputValue,
365-
role: 'user',
366-
createdAt: new Date(),
367-
},
368-
options,
369-
);
360+
361+
const chatRequest: ChatRequest = {
362+
messages: inputValue
363+
? get(messages).concat({
364+
id: generateId(),
365+
content: inputValue,
366+
role: 'user',
367+
createdAt: new Date(),
368+
} as Message)
369+
: get(messages),
370+
options: options.options,
371+
data: options.data,
372+
};
373+
374+
triggerRequest(chatRequest);
375+
370376
input.set('');
371377
};
372378

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

+15-11
Original file line numberDiff line numberDiff line change
@@ -513,19 +513,23 @@ By default, it's set to 0, which will disable the feature.
513513

514514
event?.preventDefault?.();
515515

516-
if (!input) return;
517-
518-
append(
519-
{
520-
content: input,
521-
role: 'user',
522-
createdAt: new Date(),
523-
},
524-
options,
525-
);
516+
const chatRequest: ChatRequest = {
517+
messages: input
518+
? messagesRef.current.concat({
519+
id: generateId(),
520+
role: 'user',
521+
content: input,
522+
})
523+
: messagesRef.current,
524+
options: options.options,
525+
data: options.data,
526+
};
527+
528+
triggerRequest(chatRequest);
529+
526530
setInput('');
527531
},
528-
[input, append],
532+
[input, generateId, triggerRequest],
529533
);
530534

531535
const handleInputChange = (e: any) => {

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

+78
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,84 @@ describe('text stream', () => {
205205
});
206206
});
207207

208+
describe('form actions', () => {
209+
const TestComponent = () => {
210+
const {
211+
messages,
212+
append,
213+
handleSubmit,
214+
handleInputChange,
215+
isLoading,
216+
input,
217+
} = useChat({
218+
streamMode: 'text',
219+
});
220+
221+
return (
222+
<div>
223+
{messages.map((m, idx) => (
224+
<div data-testid={`message-${idx}`} key={m.id}>
225+
{m.role === 'user' ? 'User: ' : 'AI: '}
226+
{m.content}
227+
</div>
228+
))}
229+
230+
<form onSubmit={handleSubmit} className="fixed bottom-0 p-2 w-full">
231+
<input
232+
value={input}
233+
placeholder="Send message..."
234+
onChange={handleInputChange}
235+
className="bg-zinc-100 w-full p-2"
236+
disabled={isLoading}
237+
data-testid="do-input"
238+
/>
239+
</form>
240+
</div>
241+
);
242+
};
243+
244+
beforeEach(() => {
245+
render(<TestComponent />);
246+
});
247+
248+
afterEach(() => {
249+
vi.restoreAllMocks();
250+
cleanup();
251+
});
252+
253+
it('should show streamed response using handleSubmit', async () => {
254+
mockFetchDataStream({
255+
url: 'https://example.com/api/chat',
256+
chunks: ['Hello', ',', ' world', '.'],
257+
});
258+
259+
const firstInput = screen.getByTestId('do-input');
260+
await userEvent.type(firstInput, 'hi');
261+
await userEvent.keyboard('{Enter}');
262+
263+
await screen.findByTestId('message-0');
264+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
265+
266+
await screen.findByTestId('message-1');
267+
expect(screen.getByTestId('message-1')).toHaveTextContent(
268+
'AI: Hello, world.',
269+
);
270+
271+
mockFetchDataStream({
272+
url: 'https://example.com/api/chat',
273+
chunks: ['How', ' can', ' I', ' help', ' you', '?'],
274+
});
275+
276+
const secondInput = screen.getByTestId('do-input');
277+
await userEvent.type(secondInput, '{Enter}');
278+
279+
await screen.findByTestId('message-2');
280+
expect(screen.getByTestId('message-2')).toHaveTextContent(
281+
'AI: How can I help you?',
282+
);
283+
});
284+
});
285+
208286
describe('prepareRequestBody', () => {
209287
let bodyOptions: any;
210288

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

+16-10
Original file line numberDiff line numberDiff line change
@@ -374,16 +374,22 @@ export function useChat(
374374

375375
event?.preventDefault?.();
376376
const inputValue = input();
377-
if (!inputValue) return;
378-
379-
append(
380-
{
381-
content: inputValue,
382-
role: 'user',
383-
createdAt: new Date(),
384-
},
385-
options,
386-
);
377+
378+
const chatRequest: ChatRequest = {
379+
messages: inputValue
380+
? messagesRef.concat({
381+
id: generateId()(),
382+
role: 'user',
383+
content: inputValue,
384+
createdAt: new Date(),
385+
})
386+
: messagesRef,
387+
options: options.options,
388+
data: options.data,
389+
};
390+
391+
triggerRequest(chatRequest);
392+
387393
setInput('');
388394
};
389395

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

+79
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,82 @@ describe('maxToolRoundtrips', () => {
419419
});
420420
});
421421
});
422+
423+
describe('form actions', () => {
424+
const TestComponent = () => {
425+
const { messages, handleSubmit, handleInputChange, isLoading, input } =
426+
useChat();
427+
428+
return (
429+
<div>
430+
<For each={messages()}>
431+
{(m, idx) => (
432+
<div data-testid={`message-${idx()}`}>
433+
{m.role === 'user' ? 'User: ' : 'AI: '}
434+
{m.content}
435+
</div>
436+
)}
437+
</For>
438+
439+
<form onSubmit={handleSubmit}>
440+
<input
441+
value={input()}
442+
placeholder="Send message..."
443+
onInput={handleInputChange}
444+
disabled={isLoading()}
445+
data-testid="do-input"
446+
/>
447+
</form>
448+
</div>
449+
);
450+
};
451+
452+
beforeEach(() => {
453+
render(() => <TestComponent />);
454+
});
455+
456+
afterEach(() => {
457+
vi.restoreAllMocks();
458+
cleanup();
459+
});
460+
461+
it('should show streamed response using handleSubmit', async () => {
462+
mockFetchDataStream({
463+
url: 'https://example.com/api/chat',
464+
chunks: ['Hello', ',', ' world', '.'].map(token =>
465+
formatStreamPart('text', token),
466+
),
467+
});
468+
469+
const input = screen.getByTestId('do-input');
470+
await userEvent.type(input, 'hi');
471+
await userEvent.keyboard('{Enter}');
472+
expect(input).toHaveValue('');
473+
474+
// Wait for the user message to appear
475+
await screen.findByTestId('message-0');
476+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
477+
478+
// Wait for the AI response to complete
479+
await screen.findByTestId('message-1');
480+
expect(screen.getByTestId('message-1')).toHaveTextContent(
481+
'AI: Hello, world.',
482+
);
483+
484+
mockFetchDataStream({
485+
url: 'https://example.com/api/chat',
486+
chunks: ['How', ' can', ' I', ' help', ' you', '?'].map(token =>
487+
formatStreamPart('text', token),
488+
),
489+
});
490+
491+
await userEvent.click(input);
492+
await userEvent.keyboard('{Enter}');
493+
494+
// Wait for the second AI response to complete
495+
await screen.findByTestId('message-2');
496+
expect(screen.getByTestId('message-2')).toHaveTextContent(
497+
'AI: How can I help you?',
498+
);
499+
});
500+
});

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

+16-10
Original file line numberDiff line numberDiff line change
@@ -354,16 +354,22 @@ export function useChat({
354354
) => {
355355
event?.preventDefault?.();
356356
const inputValue = get(input);
357-
if (!inputValue) return;
358-
359-
append(
360-
{
361-
content: inputValue,
362-
role: 'user',
363-
createdAt: new Date(),
364-
},
365-
options,
366-
);
357+
358+
const chatRequest: ChatRequest = {
359+
messages: inputValue
360+
? get(messages).concat({
361+
id: generateId(),
362+
content: inputValue,
363+
role: 'user',
364+
createdAt: new Date(),
365+
} as Message)
366+
: get(messages),
367+
options: options.options,
368+
data: options.data,
369+
};
370+
371+
triggerRequest(chatRequest);
372+
367373
input.set('');
368374
};
369375

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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="handleSubmit">
19+
<input
20+
:data-testid="`do-input`"
21+
v-model="input"
22+
type="text"
23+
placeholder="Type a message..."
24+
/>
25+
</form>
26+
27+
</div>
28+
</template>

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

+10-6
Original file line numberDiff line numberDiff line change
@@ -261,14 +261,18 @@ export function useChat({
261261
event?.preventDefault?.();
262262

263263
const inputValue = input.value;
264-
if (!inputValue) return;
265-
append(
266-
{
267-
content: inputValue,
268-
role: 'user',
269-
},
264+
265+
triggerRequest(
266+
inputValue
267+
? messages.value.concat({
268+
id: generateId(),
269+
content: inputValue,
270+
role: 'user',
271+
})
272+
: messages.value,
270273
options,
271274
);
275+
272276
input.value = '';
273277
};
274278

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

+49
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import userEvent from '@testing-library/user-event';
88
import { cleanup, findByText, render, screen } from '@testing-library/vue';
99
import TestChatComponent from './TestChatComponent.vue';
1010
import TestChatTextStreamComponent from './TestChatTextStreamComponent.vue';
11+
import TestChatFormComponent from './TestChatFormComponent.vue';
12+
import { formatStreamPart } from '@ai-sdk/ui-utils';
1113

1214
describe('stream data stream', () => {
1315
beforeEach(() => {
@@ -130,3 +132,50 @@ describe('text stream', () => {
130132
);
131133
});
132134
});
135+
136+
describe('form actions', () => {
137+
beforeEach(() => {
138+
render(TestChatFormComponent);
139+
});
140+
141+
afterEach(() => {
142+
vi.restoreAllMocks();
143+
cleanup();
144+
});
145+
146+
it('should show streamed response using handleSubmit', async () => {
147+
mockFetchDataStream({
148+
url: 'https://example.com/api/chat',
149+
chunks: ['Hello', ',', ' world', '.'].map(token =>
150+
formatStreamPart('text', token),
151+
),
152+
});
153+
154+
const firstInput = screen.getByTestId('do-input');
155+
await userEvent.type(firstInput, 'hi');
156+
await userEvent.keyboard('{Enter}');
157+
158+
await screen.findByTestId('message-0');
159+
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
160+
161+
await screen.findByTestId('message-1');
162+
expect(screen.getByTestId('message-1')).toHaveTextContent(
163+
'AI: Hello, world.',
164+
);
165+
166+
mockFetchDataStream({
167+
url: 'https://example.com/api/chat',
168+
chunks: ['How', ' can', ' I', ' help', ' you', '?'].map(token =>
169+
formatStreamPart('text', token),
170+
),
171+
});
172+
173+
const secondInput = screen.getByTestId('do-input');
174+
await userEvent.type(secondInput, '{Enter}');
175+
176+
await screen.findByTestId('message-2');
177+
expect(screen.getByTestId('message-2')).toHaveTextContent(
178+
'AI: How can I help you?',
179+
);
180+
});
181+
});

0 commit comments

Comments
 (0)
Please sign in to comment.