Skip to content

Commit c908f74

Browse files
lgrammelian-pascoe
andauthoredJun 26, 2024··
chore (ui/solid): update solidjs useChat and useCompletion to feature parity with React (#2095)
Co-authored-by: Ian Pascoe <ian.g.pascoe@gmail.com> Co-authored-by: Ian Pascoe <122142608+spiritledsoftware@users.noreply.github.com>
1 parent ef7bc10 commit c908f74

36 files changed

+2948
-2345
lines changed
 

‎.changeset/healthy-actors-learn.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ai-sdk/solid': patch
3+
'ai': patch
4+
---
5+
6+
chore (ui/solid): update solidjs useChat and useCompletion to feature parity with React

‎content/docs/05-ai-sdk-ui/01-overview.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Here is a comparison of the supported functions across these frameworks:
2424
| Function | React | Svelte | Vue.js | SolidJS |
2525
| ---------------------------------------------------------- | ------------------- | ------------------- | ------------------- | ------------------- |
2626
| [useChat](/docs/reference/ai-sdk-ui/use-chat) | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
27-
| [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Cross size={18} /> |
27+
| [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Check size={18} /> |
2828
| [useCompletion](/docs/reference/ai-sdk-ui/use-completion) | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
2929
| [useObject](/docs/reference/ai-sdk-ui/use-object) | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Cross size={18} /> |
3030
| [useAssistant](/docs/reference/ai-sdk-ui/use-assistant) | <Check size={18} /> | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> |

‎content/docs/05-ai-sdk-ui/03-chatbot-with-tool-calling.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Learn how to use tools with the useChat hook.
77

88
<Note type="warning">
99
The tool calling functionality described here is currently only available for
10-
**React**.
10+
**React** and **SolidJS**.
1111
</Note>
1212

1313
With `useChat` and `streamText`, you can use tools in your chatbot application.

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Allows you to easily create a conversational user interface for your chatbot app
118118
name: 'maxToolRoundtrips',
119119
type: 'number',
120120
description:
121-
'React only. Maximal number of automatic roundtrips for tool calls. An automatic tool call roundtrip is a call to the server with the tool call results when all tool calls in the last assistant message have results. A maximum number is required to prevent infinite loops in the case of misconfigured tools. By default, it is set to 0, which will disable the feature.',
121+
'React and SolidJS only. Maximal number of automatic roundtrips for tool calls. An automatic tool call roundtrip is a call to the server with the tool call results when all tool calls in the last assistant message have results. A maximum number is required to prevent infinite loops in the case of misconfigured tools. By default, it is set to 0, which will disable the feature.',
122122
},
123123
{
124124
name: 'streamMode',
@@ -247,7 +247,7 @@ Allows you to easily create a conversational user interface for your chatbot app
247247
name: 'addToolResult',
248248
type: '({toolCallId: string; result: any;}) => void',
249249
description:
250-
'React only. Function to add a tool result to the chat. This will update the chat messages with the tool result and call the API route if all tool results for the last message are available.',
250+
'React and SolidJS only. Function to add a tool result to the chat. This will update the chat messages with the tool result and call the API route if all tool results for the last message are available.',
251251
},
252252
]}
253253
/>

‎content/docs/07-reference/ai-sdk-ui/index.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Here is a comparison of the supported functions across these frameworks:
5757
| Function | React | Svelte | Vue.js | SolidJS |
5858
| ---------------------------------------------------------- | ------------------- | ------------------- | ------------------- | ------------------- |
5959
| [useChat](/docs/reference/ai-sdk-ui/use-chat) | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
60-
| [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Cross size={18} /> |
60+
| [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Check size={18} /> |
6161
| [useCompletion](/docs/reference/ai-sdk-ui/use-completion) | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
6262
| [useObject](/docs/reference/ai-sdk-ui/use-object) | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> | <Cross size={18} /> |
6363
| [useAssistant](/docs/reference/ai-sdk-ui/use-assistant) | <Check size={18} /> | <Check size={18} /> | <Cross size={18} /> | <Cross size={18} /> |

‎examples/solidstart-openai/.gitignore

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ dist
44
.output
55
.vercel
66
.netlify
7-
netlify
7+
.vinxi
8+
9+
# Environment
10+
.env
11+
.env*.local
812

913
# dependencies
1014
/node_modules
@@ -22,6 +26,3 @@ gitignore
2226
# System Files
2327
.DS_Store
2428
Thumbs.db
25-
26-
# Local env files
27-
.env
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineConfig } from '@solidjs/start/config';
2+
3+
export default defineConfig({});
+15-19
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,29 @@
11
{
22
"name": "solidstart-openai",
33
"private": true,
4+
"type": "module",
45
"scripts": {
5-
"dev": "solid-start dev",
6-
"build": "solid-start build",
7-
"start": "solid-start start"
6+
"dev": "vinxi dev",
7+
"build": "vinxi build",
8+
"start": "vinxi start"
89
},
9-
"type": "module",
1010
"devDependencies": {
11-
"autoprefixer": "^10.4.13",
12-
"postcss": "^8.4.21",
13-
"solid-start-node": "0.3.10",
14-
"tailwindcss": "^3.2.4",
15-
"typescript": "^4.9.4",
16-
"vite": "^4.1.4"
11+
"autoprefixer": "^10.4.19",
12+
"postcss": "^8.4.38",
13+
"tailwindcss": "^3.4.3",
14+
"vinxi": "^0.3.12"
1715
},
1816
"dependencies": {
1917
"@ai-sdk/openai": "latest",
2018
"@ai-sdk/solid": "latest",
21-
"@solidjs/meta": "0.29.3",
22-
"@solidjs/router": "0.8.2",
19+
"@solidjs/meta": "0.29.4",
20+
"@solidjs/router": "^0.13.6",
21+
"@solidjs/start": "^1.0.2",
2322
"ai": "latest",
24-
"openai": "4.47.1",
25-
"solid-js": "1.8.7",
26-
"solid-start": "0.3.10",
27-
"undici": "^5.15.1"
23+
"solid-js": "^1.8.17",
24+
"zod": "^3.23.8"
2825
},
2926
"engines": {
30-
"node": ">=16"
31-
},
32-
"version": "0.0.0"
27+
"node": ">=18"
28+
}
3329
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = {
22
plugins: {
33
tailwindcss: {},
4-
autoprefixer: {}
5-
}
4+
autoprefixer: {},
5+
},
66
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
@tailwind base;
22
@tailwind components;
3-
@tailwind utilities;
3+
@tailwind utilities;
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Router } from '@solidjs/router';
2+
import { FileRoutes } from '@solidjs/start/router';
3+
import { Suspense } from 'solid-js';
4+
5+
import './app.css';
6+
7+
export default function App() {
8+
return (
9+
<Router
10+
root={props => (
11+
<>
12+
<Suspense>{props.children}</Suspense>
13+
</>
14+
)}
15+
>
16+
<FileRoutes />
17+
</Router>
18+
);
19+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
import { mount, StartClient } from 'solid-start/entry-client';
1+
// @refresh reload
2+
import { mount, StartClient } from '@solidjs/start/client';
23

3-
mount(() => <StartClient />, document);
4+
mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import {
2-
StartServer,
3-
createHandler,
4-
renderAsync,
5-
} from 'solid-start/entry-server';
1+
// @refresh reload
2+
import { createHandler, StartServer } from '@solidjs/start/server';
63

7-
export default createHandler(
8-
renderAsync(event => <StartServer event={event} />),
9-
);
4+
export default createHandler(() => (
5+
<StartServer
6+
document={({ assets, children, scripts }) => (
7+
<html lang="en">
8+
<head>
9+
<meta charset="utf-8" />
10+
<meta name="viewport" content="width=device-width, initial-scale=1" />
11+
<link rel="icon" href="/favicon.ico" />
12+
{assets}
13+
</head>
14+
<body>
15+
<div id="app">{children}</div>
16+
{scripts}
17+
</body>
18+
</html>
19+
)}
20+
/>
21+
));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="@solidjs/start/env" />

‎examples/solidstart-openai/src/root.tsx

-36
This file was deleted.

‎examples/solidstart-openai/src/routes/api/chat-with-functions/index.ts

-92
This file was deleted.

‎examples/solidstart-openai/src/routes/api/chat-with-vision/index.ts

-43
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
import { openai } from '@ai-sdk/openai';
2-
import { StreamingTextResponse, streamText } from 'ai';
3-
import { APIEvent } from 'solid-start/api';
2+
import { convertToCoreMessages, streamText } from 'ai';
3+
import { APIEvent } from '@solidjs/start/server';
44

55
export const POST = async (event: APIEvent) => {
6-
try {
7-
const { messages } = await event.request.json();
6+
const { messages } = await event.request.json();
87

9-
const result = await streamText({
10-
model: openai('gpt-4-turbo-preview'),
11-
messages,
12-
});
8+
const result = await streamText({
9+
model: openai('gpt-3.5-turbo'),
10+
messages: convertToCoreMessages(messages),
11+
});
1312

14-
return new StreamingTextResponse(result.toAIStream());
15-
} catch (error) {
16-
console.error(error);
17-
throw error;
18-
}
13+
return result.toAIStreamResponse();
1914
};
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
1-
import { OpenAIStream, StreamingTextResponse, StreamData } from 'ai';
2-
import OpenAI from 'openai';
3-
4-
import { APIEvent } from 'solid-start/api';
5-
6-
// Create an OpenAI API client
7-
const openai = new OpenAI({
8-
apiKey: process.env['OPENAI_API_KEY'] || '',
9-
});
1+
import { StreamingTextResponse, StreamData, streamText } from 'ai';
2+
import { APIEvent } from '@solidjs/start/server';
3+
import { openai } from '@ai-sdk/openai';
104

115
export const POST = async (event: APIEvent) => {
126
// Extract the `prompt` from the body of the request
137
const { prompt } = await event.request.json();
148

15-
// Ask OpenAI for a streaming chat completion given the prompt
16-
const response = await openai.chat.completions.create({
17-
model: 'gpt-3.5-turbo',
18-
stream: true,
9+
const result = await streamText({
10+
model: openai('gpt-3.5-turbo'),
1911
messages: [{ role: 'user', content: prompt }],
2012
});
2113

2214
// optional: use stream data
2315
const data = new StreamData();
24-
2516
data.append({ test: 'value' });
2617

27-
// Convert the response into a friendly text-stream
28-
const stream = OpenAIStream(response, {
29-
onFinal(completion) {
30-
data.close();
31-
},
32-
});
33-
3418
// Respond with the stream
35-
return new StreamingTextResponse(stream, {}, data);
19+
return new StreamingTextResponse(
20+
result.toAIStream({
21+
onFinal: () => {
22+
data.close();
23+
},
24+
}),
25+
{},
26+
data,
27+
);
3628
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { APIEvent } from '@solidjs/start/server';
3+
import { convertToCoreMessages, streamText } from 'ai';
4+
import { z } from 'zod';
5+
6+
export const POST = async (event: APIEvent) => {
7+
const { messages } = await event.request.json();
8+
9+
const result = await streamText({
10+
model: openai('gpt-4-turbo'),
11+
messages: convertToCoreMessages(messages),
12+
tools: {
13+
// server-side tool with execute function:
14+
getWeatherInformation: {
15+
description: 'show the weather in a given city to the user',
16+
parameters: z.object({ city: z.string() }),
17+
execute: async ({}: { city: string }) => {
18+
const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy', 'windy'];
19+
return weatherOptions[
20+
Math.floor(Math.random() * weatherOptions.length)
21+
];
22+
},
23+
},
24+
// client-side tool that starts user interaction:
25+
askForConfirmation: {
26+
description: 'Ask the user for confirmation.',
27+
parameters: z.object({
28+
message: z.string().describe('The message to ask for confirmation.'),
29+
}),
30+
},
31+
// client-side tool that is automatically executed on the client:
32+
getLocation: {
33+
description:
34+
'Get the user location. Always ask for confirmation before using this tool.',
35+
parameters: z.object({}),
36+
},
37+
},
38+
});
39+
40+
return result.toAIStreamResponse();
41+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { StreamingTextResponse, convertToCoreMessages, streamText } from 'ai';
2+
import { APIEvent } from '@solidjs/start/server';
3+
import { openai } from '@ai-sdk/openai';
4+
5+
export const POST = async (event: APIEvent) => {
6+
// 'data' contains the additional data that you have sent:
7+
const { messages, data } = await event.request.json();
8+
9+
const initialMessages = messages.slice(0, -1);
10+
const currentMessage = messages[messages.length - 1];
11+
12+
const result = await streamText({
13+
model: openai('gpt-4o'),
14+
messages: [
15+
...convertToCoreMessages(initialMessages),
16+
{
17+
...currentMessage,
18+
content: [
19+
{ type: 'text', text: currentMessage.content },
20+
{
21+
type: 'image',
22+
image: new URL(data.imageUrl),
23+
},
24+
],
25+
},
26+
],
27+
});
28+
29+
// Respond with the stream
30+
return new StreamingTextResponse(result.toAIStream());
31+
};

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

-90
This file was deleted.

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

+2-9
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import { For, JSX } from 'solid-js';
1+
import { For } from 'solid-js';
22
import { useChat } from '@ai-sdk/solid';
33

44
export default function Chat() {
5-
const { messages, input, setInput, handleSubmit } = useChat();
6-
7-
const handleInputChange: JSX.ChangeEventHandlerUnion<
8-
HTMLInputElement,
9-
Event
10-
> = e => {
11-
setInput(e.target.value);
12-
};
5+
const { messages, input, handleInputChange, handleSubmit } = useChat();
136

147
return (
158
<div class="flex flex-col w-full max-w-md py-24 mx-auto stretch">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* eslint-disable react/jsx-key */
2+
import { useChat } from '@ai-sdk/solid';
3+
import { For, Show } from 'solid-js';
4+
5+
export default function Chat() {
6+
const { messages, input, handleInputChange, handleSubmit, addToolResult } =
7+
useChat({
8+
api: '/api/use-chat-tools',
9+
maxToolRoundtrips: 5,
10+
11+
// run client-side tools that are automatically executed:
12+
async onToolCall({ toolCall }) {
13+
if (toolCall.toolName === 'getLocation') {
14+
const cities = [
15+
'New York',
16+
'Los Angeles',
17+
'Chicago',
18+
'San Francisco',
19+
];
20+
return cities[Math.floor(Math.random() * cities.length)];
21+
}
22+
},
23+
});
24+
25+
return (
26+
<div class="flex flex-col w-full max-w-md py-24 mx-auto stretch">
27+
<For each={messages()} fallback={<div>No messages</div>}>
28+
{message => (
29+
<div class="whitespace-pre-wrap">
30+
<strong>{`${message.role}: `}</strong>
31+
{message.content}
32+
<For each={message.toolInvocations || []}>
33+
{toolInvocation => (
34+
<Show
35+
fallback={
36+
<Show
37+
when={'result' in toolInvocation && toolInvocation}
38+
keyed
39+
fallback={
40+
<div class="text-gray-500">
41+
Calling {toolInvocation.toolName}...
42+
</div>
43+
}
44+
>
45+
{toolInvocation => (
46+
<div class="text-gray-500">
47+
Tool call {`${toolInvocation.toolName}: `}
48+
{toolInvocation.result}
49+
</div>
50+
)}
51+
</Show>
52+
}
53+
when={
54+
toolInvocation.toolName === 'askForConfirmation' &&
55+
toolInvocation
56+
}
57+
keyed
58+
>
59+
{toolInvocation => (
60+
<div class="text-gray-500">
61+
{toolInvocation.args.message}
62+
<div class="flex gap-2">
63+
<Show
64+
fallback={
65+
<>
66+
<button
67+
class="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
68+
onClick={() =>
69+
addToolResult({
70+
toolCallId: toolInvocation.toolCallId,
71+
result: 'Yes, confirmed.',
72+
})
73+
}
74+
>
75+
Yes
76+
</button>
77+
<button
78+
class="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700"
79+
onClick={() =>
80+
addToolResult({
81+
toolCallId: toolInvocation.toolCallId,
82+
result: 'No, denied',
83+
})
84+
}
85+
>
86+
No
87+
</button>
88+
</>
89+
}
90+
when={'result' in toolInvocation && toolInvocation}
91+
keyed
92+
>
93+
{toolInvocation => <b>{toolInvocation.result}</b>}
94+
</Show>
95+
</div>
96+
</div>
97+
)}
98+
</Show>
99+
)}
100+
</For>
101+
<br />
102+
<br />
103+
</div>
104+
)}
105+
</For>
106+
107+
<form onSubmit={handleSubmit}>
108+
<input
109+
class="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
110+
value={input()}
111+
placeholder="Say something..."
112+
onChange={handleInputChange}
113+
/>
114+
</form>
115+
</div>
116+
);
117+
}

‎examples/solidstart-openai/src/routes/vision/index.tsx ‎examples/solidstart-openai/src/routes/use-chat-vision/index.tsx

+4-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
1-
import { For, JSX } from 'solid-js';
21
import { useChat } from '@ai-sdk/solid';
2+
import { For } from 'solid-js';
33

44
export default function Chat() {
5-
const { messages, input, setInput, handleSubmit } = useChat({
6-
api: '/api/chat-with-vision',
7-
});
8-
9-
const handleInputChange: JSX.ChangeEventHandlerUnion<
10-
HTMLInputElement,
11-
Event
12-
> = e => {
13-
setInput(e.target.value);
14-
};
5+
const { messages, input, handleInputChange, handleSubmit } = useChat(() => ({
6+
api: '/api/use-chat-vision',
7+
}));
158

169
return (
1710
<div class="flex flex-col w-full max-w-md py-24 mx-auto stretch">
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/** @type {import('tailwindcss').Config} */
22
module.exports = {
3-
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
3+
content: ["./src/**/*.{html,js,jsx,ts,tsx}"],
44
theme: {
55
extend: {}
66
},
77
plugins: []
8-
}
8+
};

‎examples/solidstart-openai/tsconfig.json

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
{
22
"compilerOptions": {
3-
"allowSyntheticDefaultImports": true,
4-
"esModuleInterop": true,
53
"target": "ESNext",
64
"module": "ESNext",
7-
"moduleResolution": "node",
8-
"jsxImportSource": "solid-js",
5+
"moduleResolution": "bundler",
6+
"allowSyntheticDefaultImports": true,
7+
"esModuleInterop": true,
98
"jsx": "preserve",
9+
"jsxImportSource": "solid-js",
10+
"allowJs": true,
11+
"noEmit": true,
1012
"strict": true,
11-
"types": ["solid-start/env"],
12-
"baseUrl": "./",
13+
"types": ["vinxi/types/client"],
14+
"isolatedModules": true,
1315
"paths": {
1416
"~/*": ["./src/*"]
1517
}

‎examples/solidstart-openai/vite.config.ts

-6
This file was deleted.

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ export function useChat({
192192
headers,
193193
body,
194194
generateId = generateIdFunc,
195-
}: Omit<UseChatOptions, 'api'> & {
196-
api?: string;
195+
}: UseChatOptions & {
197196
key?: string;
197+
198198
/**
199199
@deprecated Use `maxToolRoundtrips` instead.
200200
*/

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

-4
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,6 @@ describe('stream data stream', () => {
8989

9090
await userEvent.click(screen.getByTestId('do-append'));
9191

92-
// TODO bug? the user message does not show up
93-
// await screen.findByTestId('message-0');
94-
// expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
95-
9692
await screen.findByTestId('error');
9793
expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found');
9894
});

‎packages/solid/.eslintrc.js

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
module.exports = {
22
root: true,
33
extends: ['vercel-ai'],
4+
rules: {
5+
'react-hooks/rules-of-hooks': 'off',
6+
},
47
};

‎packages/solid/package.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@
2525
}
2626
},
2727
"dependencies": {
28-
"@ai-sdk/ui-utils": "0.0.6",
29-
"swr-store": "0.10.6",
30-
"solid-swr-store": "0.10.7"
28+
"@ai-sdk/ui-utils": "0.0.6"
3129
},
3230
"devDependencies": {
3331
"@testing-library/jest-dom": "^6.4.5",

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

+330-139
Large diffs are not rendered by default.

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

+252-15
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@ import {
77
import { cleanup, findByText, render, screen } from '@solidjs/testing-library';
88
import '@testing-library/jest-dom';
99
import userEvent from '@testing-library/user-event';
10-
import { For } from 'solid-js';
10+
import { For, createSignal } from 'solid-js';
1111
import { useChat } from './use-chat';
12+
import { formatStreamPart } from '@ai-sdk/ui-utils';
1213

1314
describe('stream data stream', () => {
1415
const TestComponent = () => {
15-
const { messages, append, error, data, isLoading } = useChat();
16+
const [id, setId] = createSignal('first-id');
17+
const { messages, append, error, data, isLoading } = useChat(() => ({
18+
id: id(),
19+
}));
1620

1721
return (
1822
<div>
1923
<div data-testid="loading">{isLoading().toString()}</div>
2024
<div data-testid="error">{error()?.toString()}</div>
2125
<div data-testid="data">{JSON.stringify(data())}</div>
22-
2326
<For each={messages()}>
2427
{(m, idx) => (
2528
<div data-testid={`message-${idx()}`}>
@@ -28,13 +31,18 @@ describe('stream data stream', () => {
2831
</div>
2932
)}
3033
</For>
31-
3234
<button
33-
data-testid="button"
35+
data-testid="do-append"
3436
onClick={() => {
3537
append({ role: 'user', content: 'hi' });
3638
}}
3739
/>
40+
<button
41+
data-testid="do-change-id"
42+
onClick={() => {
43+
setId('second-id');
44+
}}
45+
/>
3846
</div>
3947
);
4048
};
@@ -48,13 +56,13 @@ describe('stream data stream', () => {
4856
cleanup();
4957
});
5058

51-
it('should return messages', async () => {
59+
it('should show streamed response', async () => {
5260
mockFetchDataStream({
5361
url: 'https://example.com/api/chat',
5462
chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
5563
});
5664

57-
await userEvent.click(screen.getByTestId('button'));
65+
await userEvent.click(screen.getByTestId('do-append'));
5866

5967
await screen.findByTestId('message-0');
6068
expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
@@ -65,13 +73,13 @@ describe('stream data stream', () => {
6573
);
6674
});
6775

68-
it('should return messages and data', async () => {
76+
it('should show streamed response with data', async () => {
6977
mockFetchDataStream({
7078
url: 'https://example.com/api/chat',
7179
chunks: ['2:[{"t1":"v1"}]\n', '0:"Hello"\n'],
7280
});
7381

74-
await userEvent.click(screen.getByTestId('button'));
82+
await userEvent.click(screen.getByTestId('do-append'));
7583

7684
await screen.findByTestId('data');
7785
expect(screen.getByTestId('data')).toHaveTextContent('[{"t1":"v1"}]');
@@ -80,10 +88,10 @@ describe('stream data stream', () => {
8088
expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
8189
});
8290

83-
it('should return error', async () => {
91+
it('should show error response', async () => {
8492
mockFetchError({ statusCode: 404, errorMessage: 'Not found' });
8593

86-
await userEvent.click(screen.getByTestId('button'));
94+
await userEvent.click(screen.getByTestId('do-append'));
8795

8896
await screen.findByTestId('error');
8997
expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found');
@@ -105,7 +113,7 @@ describe('stream data stream', () => {
105113
})(),
106114
});
107115

108-
await userEvent.click(screen.getByTestId('button'));
116+
await userEvent.click(screen.getByTestId('do-append'));
109117

110118
await screen.findByTestId('loading');
111119
expect(screen.getByTestId('loading')).toHaveTextContent('true');
@@ -119,19 +127,39 @@ describe('stream data stream', () => {
119127
it('should reset loading state on error', async () => {
120128
mockFetchError({ statusCode: 404, errorMessage: 'Not found' });
121129

122-
await userEvent.click(screen.getByTestId('button'));
130+
await userEvent.click(screen.getByTestId('do-append'));
123131

124132
await screen.findByTestId('loading');
125133
expect(screen.getByTestId('loading')).toHaveTextContent('false');
126134
});
127135
});
136+
137+
describe('id', () => {
138+
it('should clear out messages when the id changes', async () => {
139+
mockFetchDataStream({
140+
url: 'https://example.com/api/chat',
141+
chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
142+
});
143+
144+
await userEvent.click(screen.getByTestId('do-append'));
145+
146+
await screen.findByTestId('message-1');
147+
expect(screen.getByTestId('message-1')).toHaveTextContent(
148+
'AI: Hello, world.',
149+
);
150+
151+
await userEvent.click(screen.getByTestId('do-change-id'));
152+
153+
expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
154+
});
155+
});
128156
});
129157

130158
describe('text stream', () => {
131159
const TestComponent = () => {
132-
const { messages, append } = useChat({
160+
const { messages, append } = useChat(() => ({
133161
streamMode: 'text',
134-
});
162+
}));
135163

136164
return (
137165
<div>
@@ -182,3 +210,212 @@ describe('text stream', () => {
182210
);
183211
});
184212
});
213+
214+
describe('onToolCall', () => {
215+
const TestComponent = () => {
216+
const { messages, append } = useChat(() => ({
217+
async onToolCall({ toolCall }) {
218+
return `test-tool-response: ${toolCall.toolName} ${
219+
toolCall.toolCallId
220+
} ${JSON.stringify(toolCall.args)}`;
221+
},
222+
}));
223+
224+
return (
225+
<div>
226+
<For each={messages()}>
227+
{(m, idx) => (
228+
<div data-testid={`message-${idx()}`}>
229+
<For each={m.toolInvocations ?? []}>
230+
{(toolInvocation, toolIdx) =>
231+
'result' in toolInvocation ? (
232+
<div data-testid={`tool-invocation-${toolIdx()}`}>
233+
{toolInvocation.result}
234+
</div>
235+
) : null
236+
}
237+
</For>
238+
</div>
239+
)}
240+
</For>
241+
242+
<button
243+
data-testid="do-append"
244+
onClick={() => {
245+
append({ role: 'user', content: 'hi' });
246+
}}
247+
/>
248+
</div>
249+
);
250+
};
251+
252+
beforeEach(() => {
253+
render(() => <TestComponent />);
254+
});
255+
256+
afterEach(() => {
257+
vi.restoreAllMocks();
258+
cleanup();
259+
});
260+
261+
it("should invoke onToolCall when a tool call is received from the server's response", async () => {
262+
mockFetchDataStream({
263+
url: 'https://example.com/api/chat',
264+
chunks: [
265+
formatStreamPart('tool_call', {
266+
toolCallId: 'tool-call-0',
267+
toolName: 'test-tool',
268+
args: { testArg: 'test-value' },
269+
}),
270+
],
271+
});
272+
273+
await userEvent.click(screen.getByTestId('do-append'));
274+
275+
await screen.findByTestId('message-1');
276+
expect(screen.getByTestId('message-1')).toHaveTextContent(
277+
'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}',
278+
);
279+
});
280+
});
281+
282+
describe('maxToolRoundtrips', () => {
283+
describe('single automatic tool roundtrip', () => {
284+
const TestComponent = () => {
285+
const { messages, append } = useChat(() => ({
286+
async onToolCall({ toolCall }) {
287+
mockFetchDataStream({
288+
url: 'https://example.com/api/chat',
289+
chunks: [formatStreamPart('text', 'final result')],
290+
});
291+
292+
return `test-tool-response: ${toolCall.toolName} ${
293+
toolCall.toolCallId
294+
} ${JSON.stringify(toolCall.args)}`;
295+
},
296+
maxToolRoundtrips: 5,
297+
}));
298+
299+
return (
300+
<div>
301+
<For each={messages()}>
302+
{(m, idx) => (
303+
<div data-testid={`message-${idx()}`}>{m.content}</div>
304+
)}
305+
</For>
306+
307+
<button
308+
data-testid="do-append"
309+
onClick={() => {
310+
append({ role: 'user', content: 'hi' });
311+
}}
312+
/>
313+
</div>
314+
);
315+
};
316+
317+
beforeEach(() => {
318+
render(() => <TestComponent />);
319+
});
320+
321+
afterEach(() => {
322+
vi.restoreAllMocks();
323+
cleanup();
324+
});
325+
326+
it('should automatically call api when tool call gets executed via onToolCall', async () => {
327+
mockFetchDataStream({
328+
url: 'https://example.com/api/chat',
329+
chunks: [
330+
formatStreamPart('tool_call', {
331+
toolCallId: 'tool-call-0',
332+
toolName: 'test-tool',
333+
args: { testArg: 'test-value' },
334+
}),
335+
],
336+
});
337+
338+
await userEvent.click(screen.getByTestId('do-append'));
339+
340+
await screen.findByTestId('message-2');
341+
expect(screen.getByTestId('message-2')).toHaveTextContent('final result');
342+
});
343+
});
344+
345+
describe('single roundtrip with error response', () => {
346+
const TestComponent = () => {
347+
const { messages, append, error } = useChat(() => ({
348+
async onToolCall({ toolCall }) {
349+
mockFetchDataStream({
350+
url: 'https://example.com/api/chat',
351+
chunks: [formatStreamPart('error', 'some failure')],
352+
maxCalls: 1,
353+
});
354+
355+
return `test-tool-response: ${toolCall.toolName} ${
356+
toolCall.toolCallId
357+
} ${JSON.stringify(toolCall.args)}`;
358+
},
359+
maxToolRoundtrips: 5,
360+
}));
361+
362+
return (
363+
<div>
364+
<div data-testid="error">{error()?.toString()}</div>
365+
366+
<For each={messages()}>
367+
{(m, idx) => (
368+
<div data-testid={`message-${idx()}`}>
369+
<For each={m.toolInvocations ?? []}>
370+
{(toolInvocation, toolIdx) =>
371+
'result' in toolInvocation ? (
372+
<div data-testid={`tool-invocation-${toolIdx()}`}>
373+
{toolInvocation.result}
374+
</div>
375+
) : null
376+
}
377+
</For>
378+
</div>
379+
)}
380+
</For>
381+
382+
<button
383+
data-testid="do-append"
384+
onClick={() => {
385+
append({ role: 'user', content: 'hi' });
386+
}}
387+
/>
388+
</div>
389+
);
390+
};
391+
392+
beforeEach(() => {
393+
render(() => <TestComponent />);
394+
});
395+
396+
afterEach(() => {
397+
vi.restoreAllMocks();
398+
cleanup();
399+
});
400+
401+
it('should automatically call api when tool call gets executed via onToolCall', async () => {
402+
mockFetchDataStream({
403+
url: 'https://example.com/api/chat',
404+
chunks: [
405+
formatStreamPart('tool_call', {
406+
toolCallId: 'tool-call-0',
407+
toolName: 'test-tool',
408+
args: { testArg: 'test-value' },
409+
}),
410+
],
411+
});
412+
413+
await userEvent.click(screen.getByTestId('do-append'));
414+
415+
await screen.findByTestId('error');
416+
expect(screen.getByTestId('error')).toHaveTextContent(
417+
'Error: Too many calls',
418+
);
419+
});
420+
});
421+
});

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

+97-59
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@ import type {
44
UseCompletionOptions,
55
} from '@ai-sdk/ui-utils';
66
import { callCompletionApi } from '@ai-sdk/ui-utils';
7-
import { Accessor, Resource, Setter, createSignal } from 'solid-js';
8-
import { useSWRStore } from 'solid-swr-store';
9-
import { createSWRStore } from 'swr-store';
7+
import {
8+
Accessor,
9+
JSX,
10+
Setter,
11+
createEffect,
12+
createMemo,
13+
createSignal,
14+
createUniqueId,
15+
} from 'solid-js';
16+
import { createStore } from 'solid-js/store';
1017

1118
export type { UseCompletionOptions };
1219

1320
export type UseCompletionHelpers = {
1421
/** The current completion result */
15-
completion: Resource<string>;
22+
completion: Accessor<string>;
1623
/** The error object of the API request */
1724
error: Accessor<undefined | Error>;
1825
/**
@@ -34,6 +41,12 @@ export type UseCompletionHelpers = {
3441
input: Accessor<string>;
3542
/** Signal Setter to update the input value */
3643
setInput: Setter<string>;
44+
45+
/** An input/textarea-ready onChange handler to control the value of the input */
46+
handleInputChange: JSX.ChangeEventHandlerUnion<
47+
HTMLInputElement | HTMLTextAreaElement,
48+
Event
49+
>;
3750
/**
3851
* Form submission handler to automatically reset input and append a user message
3952
* @example
@@ -50,102 +63,105 @@ export type UseCompletionHelpers = {
5063
data: Accessor<JSONValue[] | undefined>;
5164
};
5265

53-
let uniqueId = 0;
54-
55-
const store: Record<string, any> = {};
56-
const completionApiStore = createSWRStore<any, string[]>({
57-
get: async (key: string) => {
58-
return store[key] ?? [];
59-
},
60-
});
61-
62-
export function useCompletion({
63-
api = '/api/completion',
64-
id,
65-
initialCompletion = '',
66-
initialInput = '',
67-
credentials,
68-
headers,
69-
body,
70-
streamMode,
71-
onResponse,
72-
onFinish,
73-
onError,
74-
}: UseCompletionOptions = {}): UseCompletionHelpers {
66+
const [store, setStore] = createStore<Record<string, string>>({});
67+
68+
export function useCompletion(
69+
rawUseCompletionOptions:
70+
| UseCompletionOptions
71+
| Accessor<UseCompletionOptions> = {},
72+
): UseCompletionHelpers {
73+
const useCompletionOptions = createMemo(() =>
74+
convertToAccessorOptions(rawUseCompletionOptions),
75+
);
76+
77+
const api = createMemo(
78+
() => useCompletionOptions().api?.() ?? '/api/completion',
79+
);
7580
// Generate an unique id for the completion if not provided.
76-
const completionId = id || `completion-${uniqueId++}`;
81+
const idKey = createMemo(
82+
() => useCompletionOptions().id?.() ?? `completion-${createUniqueId()}`,
83+
);
84+
const completionKey = createMemo(() => `${api()}|${idKey()}|completion`);
7785

78-
const key = `${api}|${completionId}`;
79-
const data = useSWRStore(completionApiStore, () => [key], {
80-
initialData: initialCompletion,
81-
});
86+
const completion = createMemo(
87+
() =>
88+
store[completionKey()] ?? useCompletionOptions().initialCompletion?.(),
89+
);
8290

8391
const mutate = (data: string) => {
84-
store[key] = data;
85-
return completionApiStore.mutate([key], {
86-
data,
87-
status: 'success',
88-
});
92+
setStore(completionKey(), data);
8993
};
9094

91-
// Because of the `initialData` option, the `data` will never be `undefined`.
92-
const completion = data as Resource<string>;
93-
9495
const [error, setError] = createSignal<undefined | Error>(undefined);
9596
const [streamData, setStreamData] = createSignal<JSONValue[] | undefined>(
9697
undefined,
9798
);
9899
const [isLoading, setIsLoading] = createSignal(false);
99100

100-
let abortController: AbortController | null = null;
101+
const [abortController, setAbortController] =
102+
createSignal<AbortController | null>(null);
103+
104+
let extraMetadata = {
105+
credentials: useCompletionOptions().credentials?.(),
106+
headers: useCompletionOptions().headers?.(),
107+
body: useCompletionOptions().body?.(),
108+
};
109+
createEffect(() => {
110+
extraMetadata = {
111+
credentials: useCompletionOptions().credentials?.(),
112+
headers: useCompletionOptions().headers?.(),
113+
body: useCompletionOptions().body?.(),
114+
};
115+
});
101116

102117
const complete: UseCompletionHelpers['complete'] = async (
103118
prompt: string,
104119
options?: RequestOptions,
105120
) => {
106121
const existingData = streamData() ?? [];
107122
return callCompletionApi({
108-
api,
123+
api: api(),
109124
prompt,
110-
credentials,
111-
headers: {
112-
...headers,
113-
...options?.headers,
114-
},
125+
credentials: useCompletionOptions().credentials?.(),
126+
headers: { ...extraMetadata.headers, ...options?.headers },
115127
body: {
116-
...body,
128+
...extraMetadata.body,
117129
...options?.body,
118130
},
119-
streamMode,
131+
streamMode: useCompletionOptions().streamMode?.(),
120132
setCompletion: mutate,
121133
setLoading: setIsLoading,
122134
setError,
123-
setAbortController: controller => {
124-
abortController = controller;
125-
},
126-
onResponse,
127-
onFinish,
128-
onError,
135+
setAbortController,
136+
onResponse: useCompletionOptions().onResponse?.(),
137+
onFinish: useCompletionOptions().onFinish?.(),
138+
onError: useCompletionOptions().onError?.(),
129139
onData: data => {
130140
setStreamData([...existingData, ...(data ?? [])]);
131141
},
132142
});
133143
};
134144

135145
const stop = () => {
136-
if (abortController) {
137-
abortController.abort();
138-
abortController = null;
146+
if (abortController()) {
147+
abortController()!.abort();
139148
}
140149
};
141150

142151
const setCompletion = (completion: string) => {
143152
mutate(completion);
144153
};
145154

146-
const [input, setInput] = createSignal(initialInput);
155+
const [input, setInput] = createSignal(
156+
useCompletionOptions().initialInput?.() ?? '',
157+
);
158+
159+
const handleInputChange: UseCompletionHelpers['handleInputChange'] =
160+
event => {
161+
setInput(event.target.value);
162+
};
147163

148-
const handleSubmit = (event?: { preventDefault?: () => void }) => {
164+
const handleSubmit: UseCompletionHelpers['handleSubmit'] = event => {
149165
event?.preventDefault?.();
150166

151167
const inputValue = input();
@@ -160,8 +176,30 @@ export function useCompletion({
160176
setCompletion,
161177
input,
162178
setInput,
179+
handleInputChange,
163180
handleSubmit,
164181
isLoading,
165182
data: streamData,
166183
};
167184
}
185+
186+
/**
187+
* Handle reactive and non-reactive useChatOptions
188+
*/
189+
function convertToAccessorOptions(
190+
options: UseCompletionOptions | Accessor<UseCompletionOptions>,
191+
) {
192+
const resolvedOptions = typeof options === 'function' ? options() : options;
193+
194+
return Object.entries(resolvedOptions).reduce(
195+
(reactiveOptions, [key, value]) => {
196+
reactiveOptions[key as keyof UseCompletionOptions] = createMemo(
197+
() => value,
198+
) as any;
199+
return reactiveOptions;
200+
},
201+
{} as {
202+
[K in keyof UseCompletionOptions]: Accessor<UseCompletionOptions[K]>;
203+
},
204+
);
205+
}

‎pnpm-lock.yaml

+1,956-1,752
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.