Skip to content

Commit 18a9655

Browse files
lgrammeljaycoolslm
andauthoredMay 15, 2024··
feat (ai/svelte): add useAssistant (#1593)
Co-authored-by: Jake Hall <jake.hallslm@gmail.com>
1 parent 7588999 commit 18a9655

File tree

11 files changed

+547
-46
lines changed

11 files changed

+547
-46
lines changed
 

‎.changeset/thick-jars-hear.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
feat (ai/svelte): add useAssistant

‎content/docs/05-ai-sdk-ui/03-openai-assistants.mdx

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ description: Learn how to use the useAssistant hook.
55

66
# OpenAI Assistants
77

8-
The `useAssistant` hook allows you to handle the client state when interacting with an OpenAI compatible assistant API. This hook is useful when you want to integrate assistant capabilities into your application, with the UI updated automatically as the assistant is streaming its execution.
8+
The `useAssistant` hook allows you to handle the client state when interacting with an OpenAI compatible assistant API.
9+
This hook is useful when you want to integrate assistant capabilities into your application,
10+
with the UI updated automatically as the assistant is streaming its execution.
11+
12+
The `useAssistant` hook is currently supported with `ai/react` and `ai/svelte`.
913

1014
## Example
1115

‎content/docs/07-reference/ai-sdk-ui/03-use-assistant.mdx

+10-2
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ description: Reference documentation for the useAssistant hook in the AI SDK UI
55

66
# `useAssistant`
77

8-
Allows you to handle the client state when interacting with an OpenAI compatible assistant API. This hook is useful when you want to integrate assistant capibilities into your application, with the UI updated automatically as the assistant is streaming its execution.
8+
Allows you to handle the client state when interacting with an OpenAI compatible assistant API.
9+
This hook is useful when you want to integrate assistant capibilities into your application,
10+
with the UI updated automatically as the assistant is streaming its execution.
911

10-
This works in conjunction with [`AssistantResponse`]() in the backend.
12+
This works in conjunction with [`AssistantResponse`](/docs/reference/stream-helpers/assistant-response) in the backend.
13+
14+
`useAssistant` is currently supported with `ai/react` and `ai/svelte`.
1115

1216
## Import
1317

1418
### React
1519

1620
<Snippet text={`import { useAssistant } from "ai/react"`} prompt={false} />
1721

22+
### Svelte
23+
24+
<Snippet text={`import { useAssistant } from "ai/svelte"`} prompt={false} />
25+
1826
<ReferenceTable packageName="react" functionName="useAssistant" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { RequestHandler } from './$types';
2+
3+
import { env } from '$env/dynamic/private';
4+
5+
import { AssistantResponse } from 'ai';
6+
import OpenAI from 'openai';
7+
8+
const openai = new OpenAI({
9+
apiKey: env.OPENAI_API_KEY || '',
10+
});
11+
12+
const homeTemperatures = {
13+
bedroom: 20,
14+
'home office': 21,
15+
'living room': 21,
16+
kitchen: 22,
17+
bathroom: 23,
18+
};
19+
20+
export const POST = (async ({ request }) => {
21+
// Parse the request body
22+
const input: {
23+
threadId: string | null;
24+
message: string;
25+
} = await request.json();
26+
27+
// Create a thread if needed
28+
const threadId = input.threadId ?? (await openai.beta.threads.create({})).id;
29+
30+
// Add a message to the thread
31+
const createdMessage = await openai.beta.threads.messages.create(threadId, {
32+
role: 'user',
33+
content: input.message,
34+
});
35+
36+
return AssistantResponse(
37+
{ threadId, messageId: createdMessage.id },
38+
async ({ forwardStream, sendDataMessage }) => {
39+
// Run the assistant on the thread
40+
const runStream = openai.beta.threads.runs.stream(threadId, {
41+
assistant_id:
42+
env.ASSISTANT_ID ??
43+
(() => {
44+
throw new Error('ASSISTANT_ID is not set');
45+
})(),
46+
});
47+
48+
// forward run status would stream message deltas
49+
let runResult = await forwardStream(runStream);
50+
51+
// status can be: queued, in_progress, requires_action, cancelling, cancelled, failed, completed, or expired
52+
while (
53+
runResult?.status === 'requires_action' &&
54+
runResult.required_action?.type === 'submit_tool_outputs'
55+
) {
56+
const tool_outputs =
57+
runResult.required_action.submit_tool_outputs.tool_calls.map(
58+
(toolCall: any) => {
59+
const parameters = JSON.parse(toolCall.function.arguments);
60+
61+
switch (toolCall.function.name) {
62+
case 'getRoomTemperature': {
63+
const temperature =
64+
homeTemperatures[
65+
parameters.room as keyof typeof homeTemperatures
66+
];
67+
68+
return {
69+
tool_call_id: toolCall.id,
70+
output: temperature.toString(),
71+
};
72+
}
73+
74+
case 'setRoomTemperature': {
75+
const oldTemperature =
76+
homeTemperatures[
77+
parameters.room as keyof typeof homeTemperatures
78+
];
79+
80+
homeTemperatures[
81+
parameters.room as keyof typeof homeTemperatures
82+
] = parameters.temperature;
83+
84+
sendDataMessage({
85+
role: 'data',
86+
data: {
87+
oldTemperature,
88+
newTemperature: parameters.temperature,
89+
description: `Temperature in ${parameters.room} changed from ${oldTemperature} to ${parameters.temperature}`,
90+
},
91+
});
92+
93+
return {
94+
tool_call_id: toolCall.id,
95+
output: `temperature set successfully`,
96+
};
97+
}
98+
99+
default:
100+
throw new Error(
101+
`Unknown tool call function: ${toolCall.function.name}`,
102+
);
103+
}
104+
},
105+
);
106+
107+
runResult = await forwardStream(
108+
openai.beta.threads.runs.submitToolOutputsStream(
109+
threadId,
110+
runResult.id,
111+
{ tool_outputs },
112+
),
113+
);
114+
}
115+
},
116+
);
117+
}) satisfies RequestHandler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Home Automation Assistant Example
2+
3+
## Setup
4+
5+
### Create OpenAI Assistant
6+
7+
[OpenAI Assistant Website](https://platform.openai.com/assistants)
8+
9+
Create a new assistant. Enable Code interpreter. Add the following functions and instructions to the assistant.
10+
11+
Then add the assistant id to the `.env` file as `ASSISTANT_ID=your-assistant-id`.
12+
13+
### Instructions
14+
15+
```
16+
You are an assistant with access to a home automation system. You can get and set the temperature in the bedroom, home office, living room, kitchen and bathroom.
17+
18+
The system uses temperature in Celsius. If the user requests Fahrenheit, you should convert the temperature to Fahrenheit.
19+
```
20+
21+
### getRoomTemperature function
22+
23+
```json
24+
{
25+
"name": "getRoomTemperature",
26+
"description": "Get the temperature in a room",
27+
"parameters": {
28+
"type": "object",
29+
"properties": {
30+
"room": {
31+
"type": "string",
32+
"enum": ["bedroom", "home office", "living room", "kitchen", "bathroom"]
33+
}
34+
},
35+
"required": ["room"]
36+
}
37+
}
38+
```
39+
40+
### setRoomTemperature function
41+
42+
```json
43+
{
44+
"name": "setRoomTemperature",
45+
"description": "Set the temperature in a room",
46+
"parameters": {
47+
"type": "object",
48+
"properties": {
49+
"room": {
50+
"type": "string",
51+
"enum": ["bedroom", "home office", "living room", "kitchen", "bathroom"]
52+
},
53+
"temperature": { "type": "number" }
54+
},
55+
"required": ["room", "temperature"]
56+
}
57+
}
58+
```
59+
60+
## Run
61+
62+
1. Run `pnpm dev` in `examples/sveltekit-openai`
63+
2. Go to http://localhost:5173/assistant
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script>
2+
import { useAssistant } from 'ai/svelte'
3+
const { messages, input, submitMessage } = useAssistant({
4+
api: '/api/assistant',
5+
});
6+
</script>
7+
8+
<svelte:head>
9+
<title>Home</title>
10+
<meta name="description" content="Svelte demo app" />
11+
</svelte:head>
12+
13+
<section>
14+
<h1>useAssistant</h1>
15+
<ul>
16+
{#each $messages as m}
17+
<strong>{m.role}</strong>
18+
{#if m.role !== 'data'}
19+
{m.content}
20+
{/if}
21+
{#if m.role === 'data'}
22+
<pre>{JSON.stringify(m.data, null, 2)}}</pre>
23+
{/if}
24+
<br/>
25+
<br/>
26+
{/each}
27+
</ul>
28+
<form on:submit={submitMessage}>
29+
<input bind:value={$input} />
30+
<button type="submit">Send</button>
31+
</form>
32+
</section>
33+
34+
<style>
35+
section {
36+
display: flex;
37+
flex-direction: column;
38+
justify-content: center;
39+
align-items: center;
40+
flex: 0.6;
41+
}
42+
43+
h1 {
44+
width: 100%;
45+
}
46+
</style>

‎packages/core/react/use-assistant.ts

+8-43
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { isAbortError } from '@ai-sdk/provider-utils';
44
import { useCallback, useRef, useState } from 'react';
55
import { generateId } from '../shared/generate-id';
66
import { readDataStream } from '../shared/read-data-stream';
7-
import { CreateMessage, Message } from '../shared/types';
8-
import { abort } from 'node:process';
9-
10-
export type AssistantStatus = 'in_progress' | 'awaiting_message';
7+
import {
8+
AssistantStatus,
9+
CreateMessage,
10+
Message,
11+
UseAssistantOptions,
12+
} from '../shared/types';
1113

1214
export type UseAssistantHelpers = {
1315
/**
@@ -16,7 +18,7 @@ export type UseAssistantHelpers = {
1618
messages: Message[];
1719

1820
/**
19-
* setState-powered method to update the messages array.
21+
* Update the message store with a new array of messages.
2022
*/
2123
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
2224

@@ -83,42 +85,6 @@ Abort the current request immediately, keep the generated tokens if any.
8385
error: undefined | unknown;
8486
};
8587

86-
export type UseAssistantOptions = {
87-
/**
88-
* The API endpoint that accepts a `{ threadId: string | null; message: string; }` object and returns an `AssistantResponse` stream.
89-
* The threadId refers to an existing thread with messages (or is `null` to create a new thread).
90-
* The message is the next message that should be appended to the thread and sent to the assistant.
91-
*/
92-
api: string;
93-
94-
/**
95-
* An optional string that represents the ID of an existing thread.
96-
* If not provided, a new thread will be created.
97-
*/
98-
threadId?: string;
99-
100-
/**
101-
* An optional literal that sets the mode of credentials to be used on the request.
102-
* Defaults to "same-origin".
103-
*/
104-
credentials?: RequestCredentials;
105-
106-
/**
107-
* An optional object of headers to be passed to the API endpoint.
108-
*/
109-
headers?: Record<string, string> | Headers;
110-
111-
/**
112-
* An optional, additional body object to be passed to the API endpoint.
113-
*/
114-
body?: object;
115-
116-
/**
117-
* An optional callback that will be called when the assistant encounters an error.
118-
*/
119-
onError?: (error: Error) => void;
120-
};
121-
12288
export function useAssistant({
12389
api,
12490
threadId: threadIdParam,
@@ -254,8 +220,7 @@ export function useAssistant({
254220
}
255221

256222
case 'error': {
257-
const errorObj = new Error(value);
258-
setError(errorObj);
223+
setError(new Error(value));
259224
break;
260225
}
261226
}

‎packages/core/shared/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ToolCall as CoreToolCall } from '../core/generate-text/tool-call';
22
import { ToolResult as CoreToolResult } from '../core/generate-text/tool-result';
33

4+
export * from './use-assistant-types';
5+
46
// https://github.com/openai/openai-node/blob/07b3504e1c40fd929f4aae1651b83afc19e3baf8/src/resources/chat/completions.ts#L146-L159
57
export interface FunctionCall {
68
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Define a type for the assistant status
2+
export type AssistantStatus = 'in_progress' | 'awaiting_message';
3+
4+
export type UseAssistantOptions = {
5+
/**
6+
* The API endpoint that accepts a `{ threadId: string | null; message: string; }` object and returns an `AssistantResponse` stream.
7+
* The threadId refers to an existing thread with messages (or is `null` to create a new thread).
8+
* The message is the next message that should be appended to the thread and sent to the assistant.
9+
*/
10+
api: string;
11+
12+
/**
13+
* An optional string that represents the ID of an existing thread.
14+
* If not provided, a new thread will be created.
15+
*/
16+
threadId?: string;
17+
18+
/**
19+
* An optional literal that sets the mode of credentials to be used on the request.
20+
* Defaults to "same-origin".
21+
*/
22+
credentials?: RequestCredentials;
23+
24+
/**
25+
* An optional object of headers to be passed to the API endpoint.
26+
*/
27+
headers?: Record<string, string> | Headers;
28+
29+
/**
30+
* An optional, additional body object to be passed to the API endpoint.
31+
*/
32+
body?: object;
33+
34+
/**
35+
* An optional callback that will be called when the assistant encounters an error.
36+
*/
37+
onError?: (error: Error) => void;
38+
};

‎packages/core/svelte/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './use-chat';
22
export * from './use-completion';
3+
export * from './use-assistant';

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

+252
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { isAbortError } from '@ai-sdk/provider-utils';
2+
import { Readable, Writable, get, writable } from 'svelte/store';
3+
import { generateId } from '../shared/generate-id';
4+
import { readDataStream } from '../shared/read-data-stream';
5+
import type {
6+
AssistantStatus,
7+
CreateMessage,
8+
Message,
9+
UseAssistantOptions,
10+
} from '../shared/types';
11+
12+
let uniqueId = 0;
13+
14+
const store: Record<string, any> = {};
15+
16+
export type UseAssistantHelpers = {
17+
/**
18+
* The current array of chat messages.
19+
*/
20+
messages: Readable<Message[]>;
21+
22+
/**
23+
* Update the message store with a new array of messages.
24+
*/
25+
setMessages: (messages: Message[]) => void;
26+
27+
/**
28+
* The current thread ID.
29+
*/
30+
threadId: Readable<string | undefined>;
31+
32+
/**
33+
* The current value of the input field.
34+
*/
35+
input: Writable<string>;
36+
37+
/**
38+
* Append a user message to the chat list. This triggers the API call to fetch
39+
* the assistant's response.
40+
* @param message The message to append
41+
* @param requestOptions Additional options to pass to the API call
42+
*/
43+
append: (
44+
message: Message | CreateMessage,
45+
requestOptions?: { data?: Record<string, string> },
46+
) => Promise<void>;
47+
48+
/**
49+
Abort the current request immediately, keep the generated tokens if any.
50+
*/
51+
stop: () => void;
52+
53+
/**
54+
* Form submission handler that automatically resets the input field and appends a user message.
55+
*/
56+
submitMessage: (
57+
e: any,
58+
requestOptions?: { data?: Record<string, string> },
59+
) => Promise<void>;
60+
61+
/**
62+
* The current status of the assistant. This can be used to show a loading indicator.
63+
*/
64+
status: Readable<AssistantStatus>;
65+
66+
/**
67+
* The error thrown during the assistant message processing, if any.
68+
*/
69+
error: Readable<undefined | Error>;
70+
};
71+
72+
export function useAssistant({
73+
api,
74+
threadId: threadIdParam,
75+
credentials,
76+
headers,
77+
body,
78+
onError,
79+
}: UseAssistantOptions): UseAssistantHelpers {
80+
// Generate a unique thread ID
81+
const threadIdStore = writable<string | undefined>(threadIdParam);
82+
83+
// Initialize message, input, status, and error stores
84+
const key = `${api}|${threadIdParam ?? `completion-${uniqueId++}`}`;
85+
const messages = writable<Message[]>(store[key] || []);
86+
const input = writable('');
87+
const status = writable<AssistantStatus>('awaiting_message');
88+
const error = writable<undefined | Error>(undefined);
89+
90+
// To manage aborting the current fetch request
91+
let abortController: AbortController | null = null;
92+
93+
// Update the message store
94+
const mutateMessages = (newMessages: Message[]) => {
95+
store[key] = newMessages;
96+
messages.set(newMessages);
97+
};
98+
99+
// Function to handle API calls and state management
100+
async function append(
101+
message: Message | CreateMessage,
102+
requestOptions?: { data?: Record<string, string> },
103+
) {
104+
status.set('in_progress');
105+
abortController = new AbortController(); // Initialize a new AbortController
106+
107+
// Add the new message to the existing array
108+
mutateMessages([
109+
...get(messages),
110+
{ ...message, id: message.id ?? generateId() },
111+
]);
112+
113+
input.set('');
114+
115+
try {
116+
const result = await fetch(api, {
117+
method: 'POST',
118+
credentials,
119+
signal: abortController.signal,
120+
headers: { 'Content-Type': 'application/json', ...headers },
121+
body: JSON.stringify({
122+
...body,
123+
// always use user-provided threadId when available:
124+
threadId: threadIdParam ?? get(threadIdStore) ?? null,
125+
message: message.content,
126+
127+
// optional request data:
128+
data: requestOptions?.data,
129+
}),
130+
});
131+
132+
if (result.body == null) {
133+
throw new Error('The response body is empty.');
134+
}
135+
136+
// Read the streamed response data
137+
for await (const { type, value } of readDataStream(
138+
result.body.getReader(),
139+
)) {
140+
switch (type) {
141+
case 'assistant_message': {
142+
mutateMessages([
143+
...get(messages),
144+
{
145+
id: value.id,
146+
role: value.role,
147+
content: value.content[0].text.value,
148+
},
149+
]);
150+
break;
151+
}
152+
153+
case 'text': {
154+
// text delta - add to last message:
155+
mutateMessages(
156+
get(messages).map((msg, index, array) => {
157+
if (index === array.length - 1) {
158+
return { ...msg, content: msg.content + value };
159+
}
160+
return msg;
161+
}),
162+
);
163+
break;
164+
}
165+
166+
case 'data_message': {
167+
mutateMessages([
168+
...get(messages),
169+
{
170+
id: value.id ?? generateId(),
171+
role: 'data',
172+
content: '',
173+
data: value.data,
174+
},
175+
]);
176+
break;
177+
}
178+
179+
case 'assistant_control_data': {
180+
threadIdStore.set(value.threadId);
181+
182+
mutateMessages(
183+
get(messages).map((msg, index, array) => {
184+
if (index === array.length - 1) {
185+
return { ...msg, id: value.messageId };
186+
}
187+
return msg;
188+
}),
189+
);
190+
191+
break;
192+
}
193+
194+
case 'error': {
195+
error.set(new Error(value));
196+
break;
197+
}
198+
}
199+
}
200+
} catch (err) {
201+
// Ignore abort errors as they are expected when the user cancels the request:
202+
if (isAbortError(error) && abortController?.signal?.aborted) {
203+
abortController = null;
204+
return;
205+
}
206+
207+
if (onError && err instanceof Error) {
208+
onError(err);
209+
}
210+
211+
error.set(err as Error);
212+
} finally {
213+
abortController = null;
214+
status.set('awaiting_message');
215+
}
216+
}
217+
218+
function setMessages(messages: Message[]) {
219+
mutateMessages(messages);
220+
}
221+
222+
function stop() {
223+
if (abortController) {
224+
abortController.abort();
225+
abortController = null;
226+
}
227+
}
228+
229+
// Function to handle form submission
230+
async function submitMessage(
231+
e: any,
232+
requestOptions?: { data?: Record<string, string> },
233+
) {
234+
e.preventDefault();
235+
const inputValue = get(input);
236+
if (!inputValue) return;
237+
238+
await append({ role: 'user', content: inputValue }, requestOptions);
239+
}
240+
241+
return {
242+
messages,
243+
error,
244+
threadId: threadIdStore,
245+
input,
246+
append,
247+
submitMessage,
248+
status,
249+
setMessages,
250+
stop,
251+
};
252+
}

0 commit comments

Comments
 (0)
Please sign in to comment.