Skip to content

Commit

Permalink
feat (ai/ui): allow empty handleSubmit submissions for useChat (#2164)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremyphilemon committed Jul 5, 2024
1 parent 580a5a3 commit 3db90c3
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 48 deletions.
10 changes: 10 additions & 0 deletions .changeset/five-lions-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'solidstart-openai': patch
'@ai-sdk/svelte': patch
'@ai-sdk/react': patch
'@ai-sdk/solid': patch
'ai': patch
'@ai-sdk/vue': patch
---

allow empty handleSubmit submissions for useChat
2 changes: 1 addition & 1 deletion examples/solidstart-openai/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function Chat() {
class="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
value={input()}
placeholder="Say something..."
onChange={handleInputChange}
onInput={handleInputChange}
/>
</form>
</div>
Expand Down
26 changes: 16 additions & 10 deletions packages/core/svelte/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,16 +357,22 @@ export function useChat({
) => {
event?.preventDefault?.();
const inputValue = get(input);
if (!inputValue) return;

append(
{
content: inputValue,
role: 'user',
createdAt: new Date(),
},
options,
);

const chatRequest: ChatRequest = {
messages: inputValue
? get(messages).concat({
id: generateId(),
content: inputValue,
role: 'user',
createdAt: new Date(),
} as Message)
: get(messages),
options: options.options,
data: options.data,
};

triggerRequest(chatRequest);

input.set('');
};

Expand Down
26 changes: 15 additions & 11 deletions packages/react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,19 +513,23 @@ By default, it's set to 0, which will disable the feature.

event?.preventDefault?.();

if (!input) return;

append(
{
content: input,
role: 'user',
createdAt: new Date(),
},
options,
);
const chatRequest: ChatRequest = {
messages: input
? messagesRef.current.concat({
id: generateId(),
role: 'user',
content: input,
})
: messagesRef.current,
options: options.options,
data: options.data,
};

triggerRequest(chatRequest);

setInput('');
},
[input, append],
[input, generateId, triggerRequest],
);

const handleInputChange = (e: any) => {
Expand Down
78 changes: 78 additions & 0 deletions packages/react/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,84 @@ describe('text stream', () => {
});
});

describe('form actions', () => {
const TestComponent = () => {
const {
messages,
append,
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={handleSubmit} className="fixed bottom-0 p-2 w-full">
<input
value={input}
placeholder="Send message..."
onChange={handleInputChange}
className="bg-zinc-100 w-full p-2"
disabled={isLoading}
data-testid="do-input"
/>
</form>
</div>
);
};

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

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

it('should show streamed response using handleSubmit', async () => {
mockFetchDataStream({
url: 'https://example.com/api/chat',
chunks: ['Hello', ',', ' world', '.'],
});

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.',
);

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

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

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

describe('prepareRequestBody', () => {
let bodyOptions: any;

Expand Down
26 changes: 16 additions & 10 deletions packages/solid/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,16 +374,22 @@ export function useChat(

event?.preventDefault?.();
const inputValue = input();
if (!inputValue) return;

append(
{
content: inputValue,
role: 'user',
createdAt: new Date(),
},
options,
);

const chatRequest: ChatRequest = {
messages: inputValue
? messagesRef.concat({
id: generateId()(),
role: 'user',
content: inputValue,
createdAt: new Date(),
})
: messagesRef,
options: options.options,
data: options.data,
};

triggerRequest(chatRequest);

setInput('');
};

Expand Down
79 changes: 79 additions & 0 deletions packages/solid/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,82 @@ describe('maxToolRoundtrips', () => {
});
});
});

describe('form actions', () => {
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={handleSubmit}>
<input
value={input()}
placeholder="Send message..."
onInput={handleInputChange}
disabled={isLoading()}
data-testid="do-input"
/>
</form>
</div>
);
};

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

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

it('should show streamed response using handleSubmit', 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}');

// Wait for the second AI response to complete
await screen.findByTestId('message-2');
expect(screen.getByTestId('message-2')).toHaveTextContent(
'AI: How can I help you?',
);
});
});
26 changes: 16 additions & 10 deletions packages/svelte/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,16 +354,22 @@ export function useChat({
) => {
event?.preventDefault?.();
const inputValue = get(input);
if (!inputValue) return;

append(
{
content: inputValue,
role: 'user',
createdAt: new Date(),
},
options,
);

const chatRequest: ChatRequest = {
messages: inputValue
? get(messages).concat({
id: generateId(),
content: inputValue,
role: 'user',
createdAt: new Date(),
} as Message)
: get(messages),
options: options.options,
data: options.data,
};

triggerRequest(chatRequest);

input.set('');
};

Expand Down
28 changes: 28 additions & 0 deletions packages/vue/src/TestChatFormComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useChat } from './use-chat';
const { messages, handleSubmit, input } = useChat();
</script>

<template>
<div class="flex flex-col w-full max-w-md py-24 mx-auto stretch">
<div
v-for="(m, idx) in messages"
key="m.id"
:data-testid="`message-${idx}`"
>
{{ m.role === 'user' ? 'User: ' : 'AI: ' }}
{{ m.content }}
</div>

<form @submit.prevent="handleSubmit">
<input
:data-testid="`do-input`"
v-model="input"
type="text"
placeholder="Type a message..."
/>
</form>

</div>
</template>
16 changes: 10 additions & 6 deletions packages/vue/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,18 @@ export function useChat({
event?.preventDefault?.();

const inputValue = input.value;
if (!inputValue) return;
append(
{
content: inputValue,
role: 'user',
},

triggerRequest(
inputValue
? messages.value.concat({
id: generateId(),
content: inputValue,
role: 'user',
})
: messages.value,
options,
);

input.value = '';
};

Expand Down
Loading

0 comments on commit 3db90c3

Please sign in to comment.