From 56bc5b3f8cd6bf914d588b4817bd5210fc291f8b Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 31 Jan 2025 18:09:15 +0530 Subject: [PATCH 1/5] feat(solid): add support for prepareRequestBody in solidjs --- .../80-send-custom-body-from-use-chat.mdx | 2 +- .../07-reference/02-ai-sdk-ui/01-use-chat.mdx | 2 +- .../src/routes/api/use-chat-request/index.ts | 24 +++++ .../src/routes/use-chat-request/index.tsx | 78 +++++++++++++++++ packages/solid/src/use-chat.ts | 38 ++++++-- packages/solid/src/use-chat.ui.test.tsx | 87 +++++++++++++++++++ 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 examples/solidstart-openai/src/routes/api/use-chat-request/index.ts create mode 100644 examples/solidstart-openai/src/routes/use-chat-request/index.tsx diff --git a/content/cookbook/01-next/80-send-custom-body-from-use-chat.mdx b/content/cookbook/01-next/80-send-custom-body-from-use-chat.mdx index 7462c1543212..f65262252dfd 100644 --- a/content/cookbook/01-next/80-send-custom-body-from-use-chat.mdx +++ b/content/cookbook/01-next/80-send-custom-body-from-use-chat.mdx @@ -8,7 +8,7 @@ tags: ['next', 'chat'] `experimental_prepareRequestBody` is an experimental feature and only - available in React. + available in React and Solid. By default, `useChat` sends all messages as well as information from the request to the server. diff --git a/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx b/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx index df8166141dc1..454e0a7646a5 100644 --- a/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx +++ b/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx @@ -192,7 +192,7 @@ Allows you to easily create a conversational user interface for your chatbot app type: '(options: { messages: Message[]; requestData?: JSONValue; requestBody?: object, id: string }) => unknown', isOptional: true, description: - 'Experimental (React only). When a function is provided, it will be used to prepare the request body for the chat API. This can be useful for customizing the request body based on the messages and data in the chat.', + 'Experimental (React & Solid only). When a function is provided, it will be used to prepare the request body for the chat API. This can be useful for customizing the request body based on the messages and data in the chat.', }, { name: 'experimental_throttle', diff --git a/examples/solidstart-openai/src/routes/api/use-chat-request/index.ts b/examples/solidstart-openai/src/routes/api/use-chat-request/index.ts new file mode 100644 index 000000000000..4847f9755bbd --- /dev/null +++ b/examples/solidstart-openai/src/routes/api/use-chat-request/index.ts @@ -0,0 +1,24 @@ +import { openai } from '@ai-sdk/openai'; +import { streamText, Message } from 'ai'; +import { APIEvent } from '@solidjs/start/server'; + +export const POST = async (event: APIEvent) => { + // Extract the `messages` from the body of the request + const { message } = await event.request.json(); + + // Implement your own logic here to add message history + const previousMessages: Message[] = []; + const messages = [...previousMessages, message]; + + // Call the language model + const result = streamText({ + model: openai('gpt-4o-mini'), + messages, + async onFinish({ text, toolCalls, toolResults, usage, finishReason }) { + // Implement your own logic here, e.g. for storing messages + }, + }); + + // Respond with the stream + return result.toDataStreamResponse(); +}; diff --git a/examples/solidstart-openai/src/routes/use-chat-request/index.tsx b/examples/solidstart-openai/src/routes/use-chat-request/index.tsx new file mode 100644 index 000000000000..ec5225a09d95 --- /dev/null +++ b/examples/solidstart-openai/src/routes/use-chat-request/index.tsx @@ -0,0 +1,78 @@ +/* eslint-disable @next/next/no-img-element */ +import { For } from 'solid-js'; +import { useChat } from '@ai-sdk/solid'; +import { createIdGenerator } from 'ai'; + +export default function Chat() { + const { + input, + messages, + handleInputChange, + handleSubmit, + isLoading, + error, + stop, + reload, + } = useChat({ + api: '/api/use-chat-request', + sendExtraMessageFields: true, + generateId: createIdGenerator({ prefix: 'msgc', size: 16 }), + + experimental_prepareRequestBody({ messages }) { + return { + message: messages[messages.length - 1], + }; + }, + }); + + return ( +
+
+ + {message => ( +
+ {message.role === 'user' ? 'User: ' : 'AI: '} + {message.content} +
+ )} +
+
+ + {isLoading() && ( +
+
Loading...
+ +
+ )} + + {error() && ( +
+
An error occurred.
+ +
+ )} + +
+ +
+
+ ); +} diff --git a/packages/solid/src/use-chat.ts b/packages/solid/src/use-chat.ts index 86c29ee56eb8..bd699d339f00 100644 --- a/packages/solid/src/use-chat.ts +++ b/packages/solid/src/use-chat.ts @@ -129,6 +129,7 @@ const processStreamedResponse = async ( fetch: FetchFunction | undefined, keepLastMessageOnError: boolean, chatId: string, + experimental_prepareRequestBody: UseChatOptions['experimental_prepareRequestBody'], ) => { // Do an optimistic update to the chat state to show the updated messages // immediately. @@ -162,7 +163,12 @@ const processStreamedResponse = async ( return await callChatApi({ api, - body: { + body: experimental_prepareRequestBody?.({ + id: chatId, + messages: chatRequest.messages, + requestData: chatRequest.data, + requestBody: chatRequest.body, + }) ?? { id: chatId, messages: constructedMessagesPayload, data: chatRequest.data, @@ -213,15 +219,36 @@ A maximum number is required to prevent infinite loops in the case of misconfigu By default, it's set to 1, which means that only a single LLM call is made. */ maxSteps?: number; + + /** + * Experimental (SolidJS only). When a function is provided, it will be used + * to prepare the request body for the chat API. This can be useful for + * customizing the request body based on the messages and data in the chat. + * + * @param id The chat ID + * @param messages The current messages in the chat + * @param requestData The data object passed in the chat request + * @param requestBody The request body object passed in the chat request + */ + experimental_prepareRequestBody?: (options: { + id: string; + messages: Message[]; + requestData?: JSONValue; + requestBody?: object; + }) => unknown; }; export function useChat( rawUseChatOptions: UseChatOptions | Accessor = {}, ): UseChatHelpers { - const useChatOptions = createMemo(() => - convertToAccessorOptions(rawUseChatOptions), - ); - + const useChatOptions = createMemo(() => ({ + ...convertToAccessorOptions(rawUseChatOptions), + // avoid awkward double invocation syntax so add it here. + experimental_prepareRequestBody: () => { + const options = convertToAccessorOptions(rawUseChatOptions); + return options.experimental_prepareRequestBody?.(); + }, + })); const api = createMemo(() => useChatOptions().api?.() ?? '/api/chat'); const generateId = createMemo( () => useChatOptions().generateId?.() ?? generateIdFunc, @@ -299,6 +326,7 @@ export function useChat( useChatOptions().fetch?.(), useChatOptions().keepLastMessageOnError?.() ?? true, chatId(), + useChatOptions().experimental_prepareRequestBody?.(), ); abortController = null; diff --git a/packages/solid/src/use-chat.ui.test.tsx b/packages/solid/src/use-chat.ui.test.tsx index ab6686eaabf7..f7a89822976e 100644 --- a/packages/solid/src/use-chat.ui.test.tsx +++ b/packages/solid/src/use-chat.ui.test.tsx @@ -7,6 +7,7 @@ import { findByText, render, screen, + fireEvent, waitFor, } from '@solidjs/testing-library'; import '@testing-library/jest-dom'; @@ -14,6 +15,92 @@ import userEvent from '@testing-library/user-event'; import { createSignal, For } from 'solid-js'; import { useChat } from './use-chat'; +describe('prepareRequestBody', () => { + let bodyOptions: any; + + const TestComponent = () => { + const { messages, append, isLoading } = useChat({ + experimental_prepareRequestBody: options => { + bodyOptions = options; + return 'test-request-body'; + }, + }); + + return ( +
+
{isLoading().toString()}
+ + {(m, idx) => ( +
+ {m.role === 'user' ? 'User: ' : 'AI: '} + {m.content} +
+ )} +
+ +
+ ); + }; + + beforeEach(async () => { + await render(() => ); + }); + + afterEach(() => { + bodyOptions = undefined; + vi.restoreAllMocks(); + }); + + it('should use prepared request body', () => + withTestServer( + { + url: '/api/chat', + type: 'stream-values', + content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'], + }, + async ({ call }) => { + fireEvent.click(screen.getByTestId('do-append')); + + await screen.findByTestId('message-0'); + expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi'); + + expect(bodyOptions).toStrictEqual({ + id: expect.any(String), + messages: [ + { + role: 'user', + content: 'hi', + id: expect.any(String), + experimental_attachments: undefined, + createdAt: expect.any(Date), + }, + ], + requestData: { 'test-data-key': 'test-data-value' }, + requestBody: { 'request-body-key': 'request-body-value' }, + }); + + expect(await call(0).getRequestBodyJson()).toBe('test-request-body'); + + await screen.findByTestId('message-1'); + expect(screen.getByTestId('message-1')).toHaveTextContent( + 'AI: Hello, world.', + ); + }, + )); +}); + describe('file attachments with data url', () => { const TestComponent = () => { const { From d00a37f3575131892b33e92df7464c7d1a8c2ed3 Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 31 Jan 2025 22:54:13 +0530 Subject: [PATCH 2/5] bug (ui/solid): Solid.js reactive computations created outside of a reactive root --- packages/solid/src/use-chat.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/solid/src/use-chat.ts b/packages/solid/src/use-chat.ts index bd699d339f00..fbdc2464d807 100644 --- a/packages/solid/src/use-chat.ts +++ b/packages/solid/src/use-chat.ts @@ -241,14 +241,16 @@ By default, it's set to 1, which means that only a single LLM call is made. export function useChat( rawUseChatOptions: UseChatOptions | Accessor = {}, ): UseChatHelpers { + const resolvedOptions = createMemo(() => convertToAccessorOptions(rawUseChatOptions)); + const prepareFn = createMemo(() => { + const opts = resolvedOptions(); + return opts.experimental_prepareRequestBody?.(); + }); const useChatOptions = createMemo(() => ({ - ...convertToAccessorOptions(rawUseChatOptions), - // avoid awkward double invocation syntax so add it here. - experimental_prepareRequestBody: () => { - const options = convertToAccessorOptions(rawUseChatOptions); - return options.experimental_prepareRequestBody?.(); - }, + ...resolvedOptions(), + experimental_prepareRequestBody: prepareFn, })); + const api = createMemo(() => useChatOptions().api?.() ?? '/api/chat'); const generateId = createMemo( () => useChatOptions().generateId?.() ?? generateIdFunc, From 88875815fe7c013a3f04dbcc88970b86f543e4da Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 31 Jan 2025 23:09:55 +0530 Subject: [PATCH 3/5] Run prettier --- packages/solid/src/use-chat.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/solid/src/use-chat.ts b/packages/solid/src/use-chat.ts index fbdc2464d807..12b70b1c610c 100644 --- a/packages/solid/src/use-chat.ts +++ b/packages/solid/src/use-chat.ts @@ -241,7 +241,9 @@ By default, it's set to 1, which means that only a single LLM call is made. export function useChat( rawUseChatOptions: UseChatOptions | Accessor = {}, ): UseChatHelpers { - const resolvedOptions = createMemo(() => convertToAccessorOptions(rawUseChatOptions)); + const resolvedOptions = createMemo(() => + convertToAccessorOptions(rawUseChatOptions), + ); const prepareFn = createMemo(() => { const opts = resolvedOptions(); return opts.experimental_prepareRequestBody?.(); From c45a3228555aff78d099b4b87ac941a40de70b6b Mon Sep 17 00:00:00 2001 From: Lars Grammel Date: Wed, 5 Feb 2025 13:35:22 +0100 Subject: [PATCH 4/5] changeset --- .changeset/tidy-badgers-return.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-badgers-return.md diff --git a/.changeset/tidy-badgers-return.md b/.changeset/tidy-badgers-return.md new file mode 100644 index 000000000000..cc46694c550e --- /dev/null +++ b/.changeset/tidy-badgers-return.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/solid': patch +--- + +feat (ui/solid): add support for prepareRequestBody From e5187e6b202612b2576dc89e25d55f1fa7f98cf7 Mon Sep 17 00:00:00 2001 From: Lars Grammel Date: Wed, 5 Feb 2025 13:45:07 +0100 Subject: [PATCH 5/5] refactor --- .../07-reference/02-ai-sdk-ui/01-use-chat.mdx | 2 +- packages/react/src/use-chat.ts | 2 +- packages/solid/src/use-chat.ts | 216 ++++++++---------- 3 files changed, 96 insertions(+), 124 deletions(-) diff --git a/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx b/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx index db574cb20788..8e03e3f59b98 100644 --- a/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx +++ b/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx @@ -189,7 +189,7 @@ Allows you to easily create a conversational user interface for your chatbot app }, { name: 'experimental_prepareRequestBody', - type: '(options: { messages: Message[]; requestData?: JSONValue; requestBody?: object, id: string }) => unknown', + type: '(options: { messages: UIMessage[]; requestData?: JSONValue; requestBody?: object, id: string }) => unknown', isOptional: true, description: 'Experimental (React & Solid only). When a function is provided, it will be used to prepare the request body for the chat API. This can be useful for customizing the request body based on the messages and data in the chat.', diff --git a/packages/react/src/use-chat.ts b/packages/react/src/use-chat.ts index 27f7d25efdde..86da33801906 100644 --- a/packages/react/src/use-chat.ts +++ b/packages/react/src/use-chat.ts @@ -126,7 +126,7 @@ export function useChat({ */ experimental_prepareRequestBody?: (options: { id: string; - messages: Message[]; + messages: UIMessage[]; requestData?: JSONValue; requestBody?: object; }) => unknown; diff --git a/packages/solid/src/use-chat.ts b/packages/solid/src/use-chat.ts index 28ff67f4dd04..cca35c28a668 100644 --- a/packages/solid/src/use-chat.ts +++ b/packages/solid/src/use-chat.ts @@ -3,7 +3,6 @@ import type { ChatRequest, ChatRequestOptions, CreateMessage, - IdGenerator, JSONValue, Message, UseChatOptions as SharedUseChatOptions, @@ -117,106 +116,6 @@ or to provide a custom fetch implementation for e.g. testing. id: string; }; -const processStreamedResponse = async ( - api: string, - chatRequest: ChatRequest, - mutate: (data: UIMessage[]) => void, - setStreamData: Setter, - streamData: Accessor, - extraMetadata: any, - messagesRef: UIMessage[], - abortController: AbortController | null, - generateId: IdGenerator, - streamProtocol: UseChatOptions['streamProtocol'] = 'data', - onFinish: UseChatOptions['onFinish'], - onResponse: UseChatOptions['onResponse'] | undefined, - onToolCall: UseChatOptions['onToolCall'] | undefined, - sendExtraMessageFields: boolean | undefined, - fetch: FetchFunction | undefined, - keepLastMessageOnError: boolean, - chatId: string, - experimental_prepareRequestBody: UseChatOptions['experimental_prepareRequestBody'], -) => { - // Do an optimistic update to the chat state to show the updated messages - // immediately. - const previousMessages = messagesRef; - const chatMessages = fillMessageParts(chatRequest.messages); - - mutate(chatMessages); - - const existingStreamData = streamData() ?? []; - - const constructedMessagesPayload = sendExtraMessageFields - ? chatMessages - : chatMessages.map( - ({ - role, - content, - experimental_attachments, - data, - annotations, - toolInvocations, - parts, - }) => ({ - role, - content, - ...(experimental_attachments !== undefined && { - experimental_attachments, - }), - ...(data !== undefined && { data }), - ...(annotations !== undefined && { annotations }), - ...(toolInvocations !== undefined && { toolInvocations }), - ...(parts !== undefined && { parts }), - }), - ); - - return await callChatApi({ - api, - body: experimental_prepareRequestBody?.({ - id: chatId, - messages: chatRequest.messages, - requestData: chatRequest.data, - requestBody: chatRequest.body, - }) ?? { - id: chatId, - messages: constructedMessagesPayload, - data: chatRequest.data, - ...extraMetadata.body, - ...chatRequest.body, - }, - streamProtocol, - credentials: extraMetadata.credentials, - headers: { - ...extraMetadata.headers, - ...chatRequest.headers, - }, - abortController: () => abortController, - restoreMessagesOnFailure() { - if (!keepLastMessageOnError) { - mutate(previousMessages); - } - }, - onResponse, - onUpdate({ message, data, replaceLastMessage }) { - mutate([ - ...(replaceLastMessage - ? chatMessages.slice(0, chatMessages.length - 1) - : chatMessages), - message, - ]); - - if (data?.length) { - setStreamData([...existingStreamData, ...data]); - } - }, - onToolCall, - onFinish, - generateId, - fetch, - lastMessage: chatMessages[chatMessages.length - 1], - }); -}; - const chatCache = new ReactiveLRU(); export type UseChatOptions = SharedUseChatOptions & { @@ -241,7 +140,7 @@ By default, it's set to 1, which means that only a single LLM call is made. */ experimental_prepareRequestBody?: (options: { id: string; - messages: Message[]; + messages: UIMessage[]; requestData?: JSONValue; requestBody?: object; }) => unknown; @@ -323,26 +222,99 @@ export function useChat( abortController = new AbortController(); - await processStreamedResponse( - api(), - chatRequest, - mutate, - setStreamData, - streamData, - extraMetadata, - messagesRef, - abortController, - generateId(), - useChatOptions().streamProtocol?.(), - useChatOptions().onFinish?.(), - useChatOptions().onResponse?.(), - useChatOptions().onToolCall?.(), - useChatOptions().sendExtraMessageFields?.(), - useChatOptions().fetch?.(), - useChatOptions().keepLastMessageOnError?.() ?? true, - chatId(), - useChatOptions().experimental_prepareRequestBody?.(), - ); + const streamProtocol = useChatOptions().streamProtocol?.() ?? 'data'; + + const onFinish = useChatOptions().onFinish?.(); + const onResponse = useChatOptions().onResponse?.(); + const onToolCall = useChatOptions().onToolCall?.(); + + const sendExtraMessageFields = + useChatOptions().sendExtraMessageFields?.(); + + const keepLastMessageOnError = + useChatOptions().keepLastMessageOnError?.() ?? true; + + const experimental_prepareRequestBody = + useChatOptions().experimental_prepareRequestBody?.(); + + // Do an optimistic update to the chat state to show the updated messages + // immediately. + const previousMessages = messagesRef; + const chatMessages = fillMessageParts(chatRequest.messages); + + mutate(chatMessages); + + const existingStreamData = streamData() ?? []; + + const constructedMessagesPayload = sendExtraMessageFields + ? chatMessages + : chatMessages.map( + ({ + role, + content, + experimental_attachments, + data, + annotations, + toolInvocations, + parts, + }) => ({ + role, + content, + ...(experimental_attachments !== undefined && { + experimental_attachments, + }), + ...(data !== undefined && { data }), + ...(annotations !== undefined && { annotations }), + ...(toolInvocations !== undefined && { toolInvocations }), + ...(parts !== undefined && { parts }), + }), + ); + + await callChatApi({ + api: api(), + body: experimental_prepareRequestBody?.({ + id: chatId(), + messages: chatMessages, + requestData: chatRequest.data, + requestBody: chatRequest.body, + }) ?? { + id: chatId(), + messages: constructedMessagesPayload, + data: chatRequest.data, + ...extraMetadata.body, + ...chatRequest.body, + }, + streamProtocol, + credentials: extraMetadata.credentials, + headers: { + ...extraMetadata.headers, + ...chatRequest.headers, + }, + abortController: () => abortController, + restoreMessagesOnFailure() { + if (!keepLastMessageOnError) { + mutate(previousMessages); + } + }, + onResponse, + onUpdate({ message, data, replaceLastMessage }) { + mutate([ + ...(replaceLastMessage + ? chatMessages.slice(0, chatMessages.length - 1) + : chatMessages), + message, + ]); + + if (data?.length) { + setStreamData([...existingStreamData, ...data]); + } + }, + onToolCall, + onFinish, + generateId: generateId(), + fetch: useChatOptions().fetch?.(), + lastMessage: chatMessages[chatMessages.length - 1], + }); abortController = null; } catch (err) {