From 244dd7796d374e99967556a0ea47d79d1631d868 Mon Sep 17 00:00:00 2001 From: Lars Grammel Date: Wed, 5 Feb 2025 14:23:26 +0100 Subject: [PATCH] feat (ui/solid): add support for prepareRequestBody (#4711) Co-authored-by: Jai --- .changeset/tidy-badgers-return.md | 5 + .../80-send-custom-body-from-use-chat.mdx | 2 +- .../07-reference/02-ai-sdk-ui/01-use-chat.mdx | 4 +- .../src/routes/api/use-chat-request/index.ts | 24 ++ .../src/routes/use-chat-request/index.tsx | 78 ++++++ packages/react/src/use-chat.ts | 2 +- packages/solid/src/use-chat.ts | 234 +++++++++--------- packages/solid/src/use-chat.ui.test.tsx | 87 +++++++ 8 files changed, 317 insertions(+), 119 deletions(-) create mode 100644 .changeset/tidy-badgers-return.md 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/.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 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 2aba6cdd7e46..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,10 +189,10 @@ 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 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/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 d50f01841a8f..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,100 +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, -) => { - // 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: { - 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 & { @@ -222,14 +127,39 @@ 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: UIMessage[]; + requestData?: JSONValue; + requestBody?: object; + }) => unknown; }; export function useChat( rawUseChatOptions: UseChatOptions | Accessor = {}, ): UseChatHelpers { - const useChatOptions = createMemo(() => + const resolvedOptions = createMemo(() => convertToAccessorOptions(rawUseChatOptions), ); + const prepareFn = createMemo(() => { + const opts = resolvedOptions(); + return opts.experimental_prepareRequestBody?.(); + }); + const useChatOptions = createMemo(() => ({ + ...resolvedOptions(), + experimental_prepareRequestBody: prepareFn, + })); const api = createMemo(() => useChatOptions().api?.() ?? '/api/chat'); const generateId = createMemo( @@ -292,25 +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(), - ); + 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) { diff --git a/packages/solid/src/use-chat.ui.test.tsx b/packages/solid/src/use-chat.ui.test.tsx index cb3efb2c33e9..6d66005ee0c0 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 { messages, handleSubmit, handleInputChange, isLoading, input } =