From 008725ec62125567a4437222eff700b99969ad72 Mon Sep 17 00:00:00 2001 From: Lars Grammel Date: Wed, 19 Jun 2024 18:47:29 +0200 Subject: [PATCH] feat (ui/react): add experimental_useObject hook (#2019) --- .changeset/clever-numbers-applaud.md | 5 + .changeset/metal-dots-burn.md | 5 + .changeset/tough-chicken-add.md | 5 + content/docs/05-ai-sdk-ui/01-overview.mdx | 8 +- .../05-ai-sdk-ui/08-object-generation.mdx | 115 +++++++++++ content/docs/05-ai-sdk-ui/index.mdx | 5 + .../ai-sdk-core/04-stream-object.mdx | 43 +++- .../ai-sdk-ui/02-use-completion.mdx | 2 +- .../07-reference/ai-sdk-ui/03-use-object.mdx | 107 ++++++++++ ...use-assistant.mdx => 20-use-assistant.mdx} | 0 ...response.mdx => 21-assistant-response.mdx} | 0 content/docs/07-reference/ai-sdk-ui/index.mdx | 8 +- .../04-streaming-object-generation.mdx | 137 +++++++++++++ .../02-next-pages/01-basics/index.mdx | 5 + .../next-openai/app/api/use-object/route.ts | 18 ++ .../next-openai/app/api/use-object/schema.ts | 16 ++ .../next-openai/app/stream-object/schema.ts | 4 +- examples/next-openai/app/use-object/page.tsx | 46 +++++ .../generate-object/stream-object.test.ts | 191 ++++++++++++++++-- .../core/generate-object/stream-object.ts | 131 +++++++++++- .../core/generate-text/stream-text.test.ts | 16 +- packages/core/core/index.ts | 2 +- packages/react/package.json | 9 +- packages/react/src/index.ts | 3 +- packages/react/src/use-assistant.ui.test.tsx | 2 +- packages/react/src/use-object.ts | 123 +++++++++++ packages/react/src/use-object.ui.test.tsx | 79 ++++++++ packages/ui-utils/package.json | 14 +- .../util => ui-utils/src}/deep-partial.ts | 0 .../util => ui-utils/src}/fix-json.test.ts | 0 .../core/util => ui-utils/src}/fix-json.ts | 0 packages/ui-utils/src/index.ts | 3 + .../src}/is-deep-equal-data.test.ts | 0 .../src}/is-deep-equal-data.ts | 0 .../src}/parse-partial-json.ts | 0 packages/ui-utils/src/test/mock-fetch.ts | 19 +- pnpm-lock.yaml | 9 + 37 files changed, 1070 insertions(+), 60 deletions(-) create mode 100644 .changeset/clever-numbers-applaud.md create mode 100644 .changeset/metal-dots-burn.md create mode 100644 .changeset/tough-chicken-add.md create mode 100644 content/docs/05-ai-sdk-ui/08-object-generation.mdx create mode 100644 content/docs/07-reference/ai-sdk-ui/03-use-object.mdx rename content/docs/07-reference/ai-sdk-ui/{03-use-assistant.mdx => 20-use-assistant.mdx} (100%) rename content/docs/07-reference/ai-sdk-ui/{04-assistant-response.mdx => 21-assistant-response.mdx} (100%) create mode 100644 content/examples/02-next-pages/01-basics/04-streaming-object-generation.mdx create mode 100644 examples/next-openai/app/api/use-object/route.ts create mode 100644 examples/next-openai/app/api/use-object/schema.ts create mode 100644 examples/next-openai/app/use-object/page.tsx create mode 100644 packages/react/src/use-object.ts create mode 100644 packages/react/src/use-object.ui.test.tsx rename packages/{core/core/util => ui-utils/src}/deep-partial.ts (100%) rename packages/{core/core/util => ui-utils/src}/fix-json.test.ts (100%) rename packages/{core/core/util => ui-utils/src}/fix-json.ts (100%) rename packages/{core/core/util => ui-utils/src}/is-deep-equal-data.test.ts (100%) rename packages/{core/core/util => ui-utils/src}/is-deep-equal-data.ts (100%) rename packages/{core/core/util => ui-utils/src}/parse-partial-json.ts (100%) diff --git a/.changeset/clever-numbers-applaud.md b/.changeset/clever-numbers-applaud.md new file mode 100644 index 000000000000..e7fbbcab4407 --- /dev/null +++ b/.changeset/clever-numbers-applaud.md @@ -0,0 +1,5 @@ +--- +'ai': patch +--- + +feat (ai): add textStream, toTextStreamResponse(), and pipeTextStreamToResponse() to streamObject diff --git a/.changeset/metal-dots-burn.md b/.changeset/metal-dots-burn.md new file mode 100644 index 000000000000..003a18993023 --- /dev/null +++ b/.changeset/metal-dots-burn.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/react': patch +--- + +feat (@ai-sdk/react): add experimental_useObject to @ai-sdk/react diff --git a/.changeset/tough-chicken-add.md b/.changeset/tough-chicken-add.md new file mode 100644 index 000000000000..267d6db35282 --- /dev/null +++ b/.changeset/tough-chicken-add.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/ui-utils': patch +--- + +chore (@ai-sdk/ui-utils): move functions diff --git a/content/docs/05-ai-sdk-ui/01-overview.mdx b/content/docs/05-ai-sdk-ui/01-overview.mdx index b866ccb85888..397fdfce1858 100644 --- a/content/docs/05-ai-sdk-ui/01-overview.mdx +++ b/content/docs/05-ai-sdk-ui/01-overview.mdx @@ -9,9 +9,10 @@ Vercel AI SDK UI is designed to help you build interactive chat, completion, and Vercel AI SDK UI provides robust abstractions that simplify the complex tasks of managing chat streams and UI updates on the frontend, enabling you to develop dynamic AI-driven interfaces more efficiently. With three main hooks — **`useChat`**, **`useCompletion`**, and **`useAssistant`** — you can incorporate real-time chat capabilities, text completions, and interactive assistant features into your app. -- **`useChat`** offers real-time streaming of chat messages, abstracting state management for inputs, messages, loading, and errors, allowing for seamless integration into any UI design. -- **`useCompletion`** enables you to handle text completions in your applications, managing chat input state and automatically updating the UI as new completions are streamed from your AI provider. -- **`useAssistant`** is designed to facilitate interaction with OpenAI-compatible assistant APIs, managing UI state and updating it automatically as responses are streamed. +- **[`useChat`](/docs/reference/ai-sdk-ui/use-chat)** offers real-time streaming of chat messages, abstracting state management for inputs, messages, loading, and errors, allowing for seamless integration into any UI design. +- **[`useCompletion`](/docs/reference/ai-sdk-ui/use-completion)** enables you to handle text completions in your applications, managing chat input state and automatically updating the UI as new completions are streamed from your AI provider. +- **[`useObject`](/docs/reference/ai-sdk-ui/use-object)** is a hook that allows you to consume streamed JSON objects, providing a simple way to handle and display structured data in your application. +- **[`useAssistant`](/docs/reference/ai-sdk-ui/use-assistant)** is designed to facilitate interaction with OpenAI-compatible assistant APIs, managing UI state and updating it automatically as responses are streamed. These hooks are designed to reduce the complexity and time required to implement AI interactions, letting you focus on creating exceptional user experiences. @@ -25,6 +26,7 @@ Here is a comparison of the supported functions across these frameworks: | [useChat](/docs/reference/ai-sdk-ui/use-chat) | | | | | | [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | | | | | | [useCompletion](/docs/reference/ai-sdk-ui/use-completion) | | | | | +| [useObject](/docs/reference/ai-sdk-ui/use-object) | | | | | | [useAssistant](/docs/reference/ai-sdk-ui/use-assistant) | | | | | diff --git a/content/docs/05-ai-sdk-ui/08-object-generation.mdx b/content/docs/05-ai-sdk-ui/08-object-generation.mdx new file mode 100644 index 000000000000..4cf75df9beac --- /dev/null +++ b/content/docs/05-ai-sdk-ui/08-object-generation.mdx @@ -0,0 +1,115 @@ +--- +title: Object Generation +description: Learn how to use the useObject hook. +--- + +# Object Generation + +`useObject` is an experimental feature and only available in React. + +The [`useObject`](/docs/reference/ai-sdk-ui/use-object) hook allows you to create interfaces that represent a structured JSON object that is being streamed. + +In this guide, you will learn how to use the `useObject` hook in your application to generate UIs for structured data on the fly. + +## Example + +The example shows a small notfications demo app that generates fake notifications in real-time. + +### Schema + +It is helpful to set up the schema in a separate file that is imported on both the client and server. + +```ts filename='app/api/use-object/schema.ts' +import { DeepPartial } from 'ai'; +import { z } from 'zod'; + +// define a schema for the notifications +export const notificationSchema = z.object({ + notifications: z.array( + z.object({ + name: z.string().describe('Name of a fictional person.'), + message: z.string().describe('Message. Do not use emojis or links.'), + minutesAgo: z.number(), + }), + ), +}); + +// define a type for the partial notifications during generation +export type PartialNotification = DeepPartial; +``` + +### Client + +The client uses [`useObject`](/docs/reference/ai-sdk-ui/use-object) to stream the object generation process. + +The results are partial and are displayed as they are received. +Please note the code for handling `undefined` values in the JSX. + +```tsx filename='app/page.tsx' +'use client'; + +import { experimental_useObject as useObject } from '@ai-sdk/react'; +import { notificationSchema } from './api/use-object/schema'; + +export default function Page() { + const { setInput, object } = useObject({ + api: '/api/use-object', + schema: notificationSchema, + }); + + return ( +
+ + +
+ {object?.notifications?.map((notification, index) => ( +
+
+
+

{notification?.name}

+

+ {notification?.minutesAgo} + {notification?.minutesAgo != null ? ' minutes ago' : ''} +

+
+

{notification?.message}

+
+
+ ))} +
+
+ ); +} +``` + +### Server + +On the server, we use [`streamObject`](/docs/reference/ai-sdk-core/stream-object) to stream the object generation process. + +```typescript filename='app/api/use-object/route.ts' +import { openai } from '@ai-sdk/openai' +import { streamObject } from 'ai' +import { notificationSchema } from './schema' + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30 + +export async function POST(req: Request) { + const context = await req.json() + + const result = await streamObject({ + model: openai('gpt-4-turbo'), + schema: notificationSchema + prompt: + `Generate 3 notifications for a messages app in this context:` + context, + }) + + return result.toTextStreamResponse() +} +``` diff --git a/content/docs/05-ai-sdk-ui/index.mdx b/content/docs/05-ai-sdk-ui/index.mdx index fc71057a1c48..d0510688181d 100644 --- a/content/docs/05-ai-sdk-ui/index.mdx +++ b/content/docs/05-ai-sdk-ui/index.mdx @@ -28,6 +28,11 @@ description: Learn about the Vercel AI SDK UI. description: 'Learn how to integrate an interface for text completion.', href: '/docs/ai-sdk-ui/completion', }, + { + title: 'Object Generation', + description: 'Learn how to integrate an interface for object generation.', + href: '/docs/ai-sdk-ui/object-generation', + }, { title: 'OpenAI Assistants', description: 'Learn how to integrate an interface for OpenAI Assistants.', diff --git a/content/docs/07-reference/ai-sdk-core/04-stream-object.mdx b/content/docs/07-reference/ai-sdk-core/04-stream-object.mdx index 83781a1506a6..0b2ea433f095 100644 --- a/content/docs/07-reference/ai-sdk-core/04-stream-object.mdx +++ b/content/docs/07-reference/ai-sdk-core/04-stream-object.mdx @@ -429,12 +429,19 @@ for await (const partialObject of partialObjectStream) { name: 'partialObjectStream', type: 'AsyncIterableStream>', description: - 'Note that the partial object is not validated. If you want to be certain that the actual content matches your schema, you need to implement your own validation for partial results.', + 'Stream of partial objects. It gets more complete as the stream progresses. Note that the partial object is not validated. If you want to be certain that the actual content matches your schema, you need to implement your own validation for partial results.', + }, + { + name: 'textStream', + type: 'AsyncIterableStream', + description: + 'Text stream of the JSON representation of the generated object. It contains text chunks. When the stream is finished, the object is valid JSON that can be parsed.', }, { name: 'fullStream', type: 'AsyncIterableStream>', - description: 'The full stream of the object.', + description: + 'Stream of different types of events, including partial objects, errors, and finish events.', properties: [ { type: 'ObjectPart', @@ -450,6 +457,20 @@ for await (const partialObject of partialObjectStream) { }, ], }, + { + type: 'TextDeltaPart', + parameters: [ + { + name: 'type', + type: "'text-delta'", + }, + { + name: 'textDelta', + type: 'string', + description: 'The text delta for the underlying raw JSON text.', + }, + ], + }, { type: 'ErrorPart', parameters: [ @@ -514,6 +535,18 @@ for await (const partialObject of partialObjectStream) { description: 'Warnings from the model provider (e.g. unsupported settings).', }, + { + name: 'pipeTextStreamToResponse', + type: '(response: ServerResponse, init?: { headers?: Record; status?: number } => void', + description: + 'Writes text delta output to a Node.js response-like object. It sets a `Content-Type` header to `text/plain; charset=utf-8` and writes each text delta as a separate chunk.', + }, + { + name: 'toTextStreamResponse', + type: '(init?: ResponseInit) => Response', + description: + 'Creates a simple text stream response. Each text delta is encoded as UTF-8 and sent as a separate chunk. Non-text-delta events are ignored.', + }, ]} /> @@ -522,9 +555,13 @@ for await (const partialObject of partialObjectStream) { `useObject` is an experimental feature and only available in React.
+ +Allows you to consume text streams that represent a JSON object and parse them into a complete object based on a Zod schema. +You can use it together with [`streamObject`](/docs/reference/ai-sdk-core/stream-object) in the backend. + +```tsx +'use client'; + +import { experimental_useObject as useObject } from '@ai-sdk/react'; + +export default function Page() { + const { setInput, object } = useObject({ + api: '/api/use-object', + schema: z.object({ content: z.string() }), + }); + + return ( +
+ + {object?.content &&

{object.content}

} +
+ ); +} +``` + +## Import + + + +## API Signature + +### Parameters + +', + description: + 'A Zod schema that defines the shape of the complete object.', + }, + { + name: 'id?', + type: 'string', + description: + 'Allows you to consume text streams that represent a JSON object and parse them into a complete object based on a Zod schema.', + }, + { + name: 'initialValue?', + type: 'DeepPartial | undefined', + description: 'An optional value for the initial object.', + }, + ]} +/> + +### Returns + + void', + description: 'Calls the API with the provided input as JSON body.', + }, + { + name: 'object', + type: 'DeepPartial | undefined', + description: + 'The current value for the generated object. Updated as the API streams JSON chunks.', + }, + { + name: 'error', + type: 'undefined | unknown', + description: 'The error object if the API call fails.', + + } + +]} +/> + +## Examples + + diff --git a/content/docs/07-reference/ai-sdk-ui/03-use-assistant.mdx b/content/docs/07-reference/ai-sdk-ui/20-use-assistant.mdx similarity index 100% rename from content/docs/07-reference/ai-sdk-ui/03-use-assistant.mdx rename to content/docs/07-reference/ai-sdk-ui/20-use-assistant.mdx diff --git a/content/docs/07-reference/ai-sdk-ui/04-assistant-response.mdx b/content/docs/07-reference/ai-sdk-ui/21-assistant-response.mdx similarity index 100% rename from content/docs/07-reference/ai-sdk-ui/04-assistant-response.mdx rename to content/docs/07-reference/ai-sdk-ui/21-assistant-response.mdx diff --git a/content/docs/07-reference/ai-sdk-ui/index.mdx b/content/docs/07-reference/ai-sdk-ui/index.mdx index 9e9eb9d3337a..3a6ba9c7cdf9 100644 --- a/content/docs/07-reference/ai-sdk-ui/index.mdx +++ b/content/docs/07-reference/ai-sdk-ui/index.mdx @@ -24,6 +24,11 @@ AI SDK UI contains the following hooks: 'Use a hook to interact with language models in a completion interface.', href: '/docs/reference/ai-sdk-ui/use-completion', }, + { + title: 'useObject', + description: 'Use a hook for consuming a streamed JSON objects.', + href: '/docs/reference/ai-sdk-ui/use-object', + }, { title: 'useAssistant', description: 'Use a hook to interact with OpenAI assistants.', @@ -46,7 +51,7 @@ It also contains the following helper functions: ## UI Framework Support -AI SDK UI supports several frameworks: [React](https://react.dev/), [Svelte](https://svelte.dev/), [Vue.js](https://vuejs.org/), and [SolidJS](https://www.solidjs.com/). +AI SDK UI supports the following frameworks: [React](https://react.dev/), [Svelte](https://svelte.dev/), [Vue.js](https://vuejs.org/), and [SolidJS](https://www.solidjs.com/). Here is a comparison of the supported functions across these frameworks: | Function | React | Svelte | Vue.js | SolidJS | @@ -54,6 +59,7 @@ Here is a comparison of the supported functions across these frameworks: | [useChat](/docs/reference/ai-sdk-ui/use-chat) | | | | | | [useChat](/docs/reference/ai-sdk-ui/use-chat) tool calling | | | | | | [useCompletion](/docs/reference/ai-sdk-ui/use-completion) | | | | | +| [useObject](/docs/reference/ai-sdk-ui/use-object) | | | | | | [useAssistant](/docs/reference/ai-sdk-ui/use-assistant) | | | | | diff --git a/content/examples/02-next-pages/01-basics/04-streaming-object-generation.mdx b/content/examples/02-next-pages/01-basics/04-streaming-object-generation.mdx new file mode 100644 index 000000000000..c3560bb313cb --- /dev/null +++ b/content/examples/02-next-pages/01-basics/04-streaming-object-generation.mdx @@ -0,0 +1,137 @@ +--- +title: Streaming Object Generation +description: Learn to stream object generations using the Vercel AI SDK in your Next.js App Router application +--- + +# Stream Object Generation + +Object generation can sometimes take a long time to complete, especially when you're generating a large schema. +In such cases, it is useful to stream the object generation process to the client in real-time. +This allows the client to display the generated object as it is being generated, +rather than have users wait for it to complete before displaying the result. + + + + + +## Schema + +It is helpful to set up the schema in a separate file that is imported on both the client and server. + +```ts filename='app/api/use-object/schema.ts' +import { DeepPartial } from 'ai'; +import { z } from 'zod'; + +// define a schema for the notifications +export const notificationSchema = z.object({ + notifications: z.array( + z.object({ + name: z.string().describe('Name of a fictional person.'), + message: z.string().describe('Message. Do not use emojis or links.'), + minutesAgo: z.number(), + }), + ), +}); + +// define a type for the partial notifications during generation +export type PartialNotification = DeepPartial; +``` + +## Client + +The client uses [`useObject`](/docs/reference/ai-sdk-ui/use-object) to stream the object generation process. + +The results are partial and are displayed as they are received. +Please note the code for handling `undefined` values in the JSX. + +```tsx filename='app/page.tsx' +'use client'; + +import { experimental_useObject as useObject } from '@ai-sdk/react'; +import { notificationSchema } from './api/use-object/schema'; + +export default function Page() { + const { setInput, object } = useObject({ + api: '/api/use-object', + schema: notificationSchema, + }); + + return ( +
+ + +
+ {object?.notifications?.map((notification, index) => ( +
+
+
+

{notification?.name}

+

+ {notification?.minutesAgo} + {notification?.minutesAgo != null ? ' minutes ago' : ''} +

+
+

{notification?.message}

+
+
+ ))} +
+
+ ); +} +``` + +## Server + +On the server, we use [`streamObject`](/docs/reference/ai-sdk-core/stream-object) to stream the object generation process. + +```typescript filename='app/api/use-object/route.ts' +import { openai } from '@ai-sdk/openai' +import { streamObject } from 'ai' +import { notificationSchema } from './schema' + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30 + +export async function POST(req: Request) { + const context = await req.json() + + const result = await streamObject({ + model: openai('gpt-4-turbo'), + schema: notificationSchema + prompt: + `Generate 3 notifications for a messages app in this context:` + context, + }) + + return result.toTextStreamResponse() +} +``` diff --git a/content/examples/02-next-pages/01-basics/index.mdx b/content/examples/02-next-pages/01-basics/index.mdx index bc0a370657d6..c1a40f90cece 100644 --- a/content/examples/02-next-pages/01-basics/index.mdx +++ b/content/examples/02-next-pages/01-basics/index.mdx @@ -26,5 +26,10 @@ Beyond text, you will also learn to generate structured data by providing a sche description: 'Learn how to generate structured data.', href: '/examples/next-pages/basics/generating-object', }, + { + title: 'Stream Object Generation', + description: 'Learn how to stream a structured data generation.', + href: '/examples/next-pages/basics/streaming-object-generation', + }, ]} /> diff --git a/examples/next-openai/app/api/use-object/route.ts b/examples/next-openai/app/api/use-object/route.ts new file mode 100644 index 000000000000..26cdea14e8fe --- /dev/null +++ b/examples/next-openai/app/api/use-object/route.ts @@ -0,0 +1,18 @@ +import { openai } from '@ai-sdk/openai'; +import { streamObject } from 'ai'; +import { notificationSchema } from './schema'; + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30; + +export async function POST(req: Request) { + const context = await req.json(); + + const result = await streamObject({ + model: openai('gpt-4-turbo'), + prompt: `Generate 3 notifications for a messages app in this context: ${context}`, + schema: notificationSchema, + }); + + return result.toTextStreamResponse(); +} diff --git a/examples/next-openai/app/api/use-object/schema.ts b/examples/next-openai/app/api/use-object/schema.ts new file mode 100644 index 000000000000..e63dc963f8cf --- /dev/null +++ b/examples/next-openai/app/api/use-object/schema.ts @@ -0,0 +1,16 @@ +import { DeepPartial } from 'ai'; +import { z } from 'zod'; + +// define a schema for the notifications +export const notificationSchema = z.object({ + notifications: z.array( + z.object({ + name: z.string().describe('Name of a fictional person.'), + message: z.string().describe('Message. Do not use emojis or links.'), + minutesAgo: z.number(), + }), + ), +}); + +// define a type for the partial notifications during generation +export type PartialNotification = DeepPartial; diff --git a/examples/next-openai/app/stream-object/schema.ts b/examples/next-openai/app/stream-object/schema.ts index 3ae4ce67172f..e63dc963f8cf 100644 --- a/examples/next-openai/app/stream-object/schema.ts +++ b/examples/next-openai/app/stream-object/schema.ts @@ -13,6 +13,4 @@ export const notificationSchema = z.object({ }); // define a type for the partial notifications during generation -export type PartialNotification = DeepPartial< - z.infer ->; +export type PartialNotification = DeepPartial; diff --git a/examples/next-openai/app/use-object/page.tsx b/examples/next-openai/app/use-object/page.tsx new file mode 100644 index 000000000000..344eeb7b88b1 --- /dev/null +++ b/examples/next-openai/app/use-object/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { experimental_useObject as useObject } from '@ai-sdk/react'; +import { notificationSchema } from '../api/use-object/schema'; + +export default function Page() { + const { setInput, object } = useObject({ + api: '/api/use-object', + schema: notificationSchema, + }); + + return ( +
+ + +
+ {object?.notifications?.map((notification, index) => ( +
+
+
+

{notification?.name}

+

+ {notification?.minutesAgo} + {notification?.minutesAgo != null ? ' minutes ago' : ''} +

+
+

+ {notification?.message} +

+
+
+ ))} +
+
+ ); +} diff --git a/packages/core/core/generate-object/stream-object.test.ts b/packages/core/core/generate-object/stream-object.test.ts index 9d711f841a2d..6b17e4d6940c 100644 --- a/packages/core/core/generate-object/stream-object.test.ts +++ b/packages/core/core/generate-object/stream-object.test.ts @@ -2,11 +2,13 @@ import { TypeValidationError } from '@ai-sdk/provider'; import { convertArrayToReadableStream, convertAsyncIterableToArray, + convertReadableStreamToArray, } from '@ai-sdk/provider-utils/test'; import assert from 'node:assert'; import { z } from 'zod'; import { MockLanguageModelV1 } from '../test/mock-language-model-v1'; import { streamObject } from './stream-object'; +import { createMockServerResponse } from '../test/mock-server-response'; describe('result.objectStream', () => { it('should send object deltas with json mode', async () => { @@ -148,18 +150,6 @@ describe('result.fullStream', () => { const result = await streamObject({ model: new MockLanguageModelV1({ doStream: async ({ prompt, mode }) => { - assert.deepStrictEqual(mode, { type: 'object-json' }); - assert.deepStrictEqual(prompt, [ - { - role: 'system', - content: - 'JSON schema:\n' + - '{"type":"object","properties":{"content":{"type":"string"}},"required":["content"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}\n' + - 'You MUST answer with a JSON object that matches the JSON schema above.', - }, - { role: 'user', content: [{ type: 'text', text: 'prompt' }] }, - ]); - return { stream: convertArrayToReadableStream([ { type: 'text-delta', textDelta: '{ ' }, @@ -187,10 +177,42 @@ describe('result.fullStream', () => { assert.deepStrictEqual( await convertAsyncIterableToArray(result.fullStream), [ - { type: 'object', object: {} }, - { type: 'object', object: { content: 'Hello, ' } }, - { type: 'object', object: { content: 'Hello, world' } }, - { type: 'object', object: { content: 'Hello, world!' } }, + { + type: 'object', + object: {}, + }, + { + type: 'text-delta', + textDelta: '{ ', + }, + { + type: 'object', + object: { content: 'Hello, ' }, + }, + { + type: 'text-delta', + textDelta: '"content": "Hello, ', + }, + { + type: 'object', + object: { content: 'Hello, world' }, + }, + { + type: 'text-delta', + textDelta: 'world', + }, + { + type: 'object', + object: { content: 'Hello, world!' }, + }, + { + type: 'text-delta', + textDelta: '!"', + }, + { + type: 'text-delta', + textDelta: ' }', + }, { type: 'finish', finishReason: 'stop', @@ -208,6 +230,143 @@ describe('result.fullStream', () => { }); }); +describe('result.textStream', () => { + it('should send text stream', async () => { + const result = await streamObject({ + model: new MockLanguageModelV1({ + doStream: async ({ prompt, mode }) => { + return { + stream: convertArrayToReadableStream([ + { type: 'text-delta', textDelta: '{ ' }, + { type: 'text-delta', textDelta: '"content": ' }, + { type: 'text-delta', textDelta: `"Hello, ` }, + { type: 'text-delta', textDelta: `world` }, + { type: 'text-delta', textDelta: `!"` }, + { type: 'text-delta', textDelta: ' }' }, + { + type: 'finish', + finishReason: 'stop', + usage: { completionTokens: 10, promptTokens: 2 }, + }, + ]), + rawCall: { rawPrompt: 'prompt', rawSettings: {} }, + }; + }, + }), + schema: z.object({ content: z.string() }), + mode: 'json', + prompt: 'prompt', + }); + + assert.deepStrictEqual( + await convertAsyncIterableToArray(result.textStream), + ['{ ', '"content": "Hello, ', 'world', '!"', ' }'], + ); + }); +}); + +describe('result.toTextStreamResponse', () => { + it('should create a Response with a text stream', async () => { + const result = await streamObject({ + model: new MockLanguageModelV1({ + doStream: async ({ prompt, mode }) => { + return { + stream: convertArrayToReadableStream([ + { type: 'text-delta', textDelta: '{ ' }, + { type: 'text-delta', textDelta: '"content": ' }, + { type: 'text-delta', textDelta: `"Hello, ` }, + { type: 'text-delta', textDelta: `world` }, + { type: 'text-delta', textDelta: `!"` }, + { type: 'text-delta', textDelta: ' }' }, + { + type: 'finish', + finishReason: 'stop', + usage: { completionTokens: 10, promptTokens: 2 }, + }, + ]), + rawCall: { rawPrompt: 'prompt', rawSettings: {} }, + }; + }, + }), + schema: z.object({ content: z.string() }), + mode: 'json', + prompt: 'prompt', + }); + + const response = result.toTextStreamResponse(); + + assert.strictEqual(response.status, 200); + assert.strictEqual( + response.headers.get('Content-Type'), + 'text/plain; charset=utf-8', + ); + + assert.deepStrictEqual( + await convertReadableStreamToArray( + response.body!.pipeThrough(new TextDecoderStream()), + ), + ['{ ', '"content": "Hello, ', 'world', '!"', ' }'], + ); + }); +}); + +describe('result.pipeTextStreamToResponse', async () => { + it('should write text deltas to a Node.js response-like object', async () => { + const mockResponse = createMockServerResponse(); + + const result = await streamObject({ + model: new MockLanguageModelV1({ + doStream: async ({ prompt, mode }) => { + return { + stream: convertArrayToReadableStream([ + { type: 'text-delta', textDelta: '{ ' }, + { type: 'text-delta', textDelta: '"content": ' }, + { type: 'text-delta', textDelta: `"Hello, ` }, + { type: 'text-delta', textDelta: `world` }, + { type: 'text-delta', textDelta: `!"` }, + { type: 'text-delta', textDelta: ' }' }, + { + type: 'finish', + finishReason: 'stop', + usage: { completionTokens: 10, promptTokens: 2 }, + }, + ]), + rawCall: { rawPrompt: 'prompt', rawSettings: {} }, + }; + }, + }), + schema: z.object({ content: z.string() }), + mode: 'json', + prompt: 'prompt', + }); + + result.pipeTextStreamToResponse(mockResponse); + + // Wait for the stream to finish writing to the mock response + await new Promise(resolve => { + const checkIfEnded = () => { + if (mockResponse.ended) { + resolve(undefined); + } else { + setImmediate(checkIfEnded); + } + }; + checkIfEnded(); + }); + + const decoder = new TextDecoder(); + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.headers, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + assert.deepStrictEqual( + mockResponse.writtenChunks.map(chunk => decoder.decode(chunk)), + ['{ ', '"content": "Hello, ', 'world', '!"', ' }'], + ); + }); +}); + describe('result.usage', () => { it('should resolve with token usage', async () => { const result = await streamObject({ diff --git a/packages/core/core/generate-object/stream-object.ts b/packages/core/core/generate-object/stream-object.ts index 6e6a3509a171..bacc7133a015 100644 --- a/packages/core/core/generate-object/stream-object.ts +++ b/packages/core/core/generate-object/stream-object.ts @@ -2,6 +2,12 @@ import { LanguageModelV1CallOptions, LanguageModelV1StreamPart, } from '@ai-sdk/provider'; +import { safeValidateTypes } from '@ai-sdk/provider-utils'; +import { + DeepPartial, + isDeepEqualData, + parsePartialJson, +} from '@ai-sdk/ui-utils'; import { z } from 'zod'; import { TokenUsage, calculateTokenUsage } from '../generate-text/token-usage'; import { CallSettings } from '../prompt/call-settings'; @@ -15,12 +21,10 @@ import { createAsyncIterableStream, } from '../util/async-iterable-stream'; import { convertZodToJSONSchema } from '../util/convert-zod-to-json-schema'; -import { DeepPartial } from '../util/deep-partial'; -import { isDeepEqualData } from '../util/is-deep-equal-data'; -import { parsePartialJson } from '../util/parse-partial-json'; import { retryWithExponentialBackoff } from '../util/retry-with-exponential-backoff'; import { injectJsonSchemaIntoSystem } from './inject-json-schema-into-system'; -import { safeValidateTypes } from '@ai-sdk/provider-utils'; +import { prepareResponseHeaders } from '../util/prepare-response-headers'; +import { ServerResponse } from 'http'; /** Generate a structured, typed object for a given prompt and schema using a language model. @@ -290,6 +294,10 @@ export type ObjectStreamPart = | { type: 'object'; object: DeepPartial; + } + | { + type: 'text-delta'; + textDelta: string; }; /** @@ -362,6 +370,7 @@ Response headers. // pipe chunks through a transformation stream that extracts metadata: let accumulatedText = ''; + let delta = ''; let latestObject: DeepPartial | undefined = undefined; this.originalStream = stream.pipeThrough( @@ -370,6 +379,7 @@ Response headers. // process partial text chunks if (typeof chunk === 'string') { accumulatedText += chunk; + delta += chunk; const currentObject = parsePartialJson( accumulatedText, @@ -378,7 +388,17 @@ Response headers. if (!isDeepEqualData(latestObject, currentObject)) { latestObject = currentObject; - controller.enqueue({ type: 'object', object: currentObject }); + controller.enqueue({ + type: 'object', + object: currentObject, + }); + + controller.enqueue({ + type: 'text-delta', + textDelta: delta, + }); + + delta = ''; } return; @@ -386,6 +406,14 @@ Response headers. switch (chunk.type) { case 'finish': { + // send final text delta: + if (delta !== '') { + controller.enqueue({ + type: 'text-delta', + textDelta: delta, + }); + } + // store usage for promises and onFinish callback: usage = calculateTokenUsage(chunk.usage); @@ -441,6 +469,12 @@ Response headers. ); } + /** +Stream of partial objects. It gets more complete as the stream progresses. + +Note that the partial object is not validated. +If you want to be certain that the actual content matches your schema, you need to implement your own validation for partial results. + */ get partialObjectStream(): AsyncIterableStream> { return createAsyncIterableStream(this.originalStream, { transform(chunk, controller) { @@ -449,6 +483,7 @@ Response headers. controller.enqueue(chunk.object); break; + case 'text-delta': case 'finish': break; @@ -465,6 +500,38 @@ Response headers. }); } + /** +Text stream of the JSON representation of the generated object. It contains text chunks. +When the stream is finished, the object is valid JSON that can be parsed. + */ + get textStream(): AsyncIterableStream { + return createAsyncIterableStream(this.originalStream, { + transform(chunk, controller) { + switch (chunk.type) { + case 'text-delta': + controller.enqueue(chunk.textDelta); + break; + + case 'object': + case 'finish': + break; + + case 'error': + controller.error(chunk.error); + break; + + default: { + const _exhaustiveCheck: never = chunk; + throw new Error(`Unsupported chunk type: ${_exhaustiveCheck}`); + } + } + }, + }); + } + + /** +Stream of different types of events, including partial objects, errors, and finish events. + */ get fullStream(): AsyncIterableStream> { return createAsyncIterableStream(this.originalStream, { transform(chunk, controller) { @@ -472,6 +539,60 @@ Response headers. }, }); } + + /** +Writes text delta output to a Node.js response-like object. +It sets a `Content-Type` header to `text/plain; charset=utf-8` and +writes each text delta as a separate chunk. + +@param response A Node.js response-like object (ServerResponse). +@param init Optional headers and status code. + */ + pipeTextStreamToResponse( + response: ServerResponse, + init?: { headers?: Record; status?: number }, + ) { + response.writeHead(init?.status ?? 200, { + 'Content-Type': 'text/plain; charset=utf-8', + ...init?.headers, + }); + + const reader = this.textStream + .pipeThrough(new TextEncoderStream()) + .getReader(); + + const read = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + response.write(value); + } + } catch (error) { + throw error; + } finally { + response.end(); + } + }; + + read(); + } + + /** +Creates a simple text stream response. +Each text delta is encoded as UTF-8 and sent as a separate chunk. +Non-text-delta events are ignored. + +@param init Optional headers and status code. + */ + toTextStreamResponse(init?: ResponseInit): Response { + return new Response(this.textStream.pipeThrough(new TextEncoderStream()), { + status: init?.status ?? 200, + headers: prepareResponseHeaders(init, { + contentType: 'text/plain; charset=utf-8', + }), + }); + } } /** diff --git a/packages/core/core/generate-text/stream-text.test.ts b/packages/core/core/generate-text/stream-text.test.ts index 0801104eaab7..d90746f8fdfc 100644 --- a/packages/core/core/generate-text/stream-text.test.ts +++ b/packages/core/core/generate-text/stream-text.test.ts @@ -570,16 +570,12 @@ describe('result.toTextStreamResponse', () => { 'text/plain; charset=utf-8', ); - // Read the chunks into an array - const reader = response.body!.getReader(); - const chunks = []; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - chunks.push(new TextDecoder().decode(value)); - } - - assert.deepStrictEqual(chunks, ['Hello', ', ', 'world!']); + assert.deepStrictEqual( + await convertReadableStreamToArray( + response.body!.pipeThrough(new TextDecoderStream()), + ), + ['Hello', ', ', 'world!'], + ); }); }); diff --git a/packages/core/core/index.ts b/packages/core/core/index.ts index 73d186ecb5fa..68f5777e276c 100644 --- a/packages/core/core/index.ts +++ b/packages/core/core/index.ts @@ -5,5 +5,5 @@ export * from './prompt'; export * from './registry'; export * from './tool'; export * from './types'; -export type { DeepPartial } from './util/deep-partial'; +export type { DeepPartial } from '@ai-sdk/ui-utils'; export { cosineSimilarity } from './util/cosine-similarity'; diff --git a/packages/react/package.json b/packages/react/package.json index 01c014c8c2c4..62bde41d8bc2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -43,14 +43,19 @@ "msw": "2.0.9", "react-dom": "^18", "tsup": "^7.2.0", - "typescript": "5.1.3" + "typescript": "5.1.3", + "zod": "3.23.8" }, "peerDependencies": { - "react": "^18 || ^19" + "react": "^18 || ^19", + "zod": "^3.0.0" }, "peerDependenciesMeta": { "react": { "optional": true + }, + "zod": { + "optional": true } }, "engines": { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6a1f7b97f61c..4882a35c91d1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ +export * from './use-assistant'; export * from './use-chat'; export * from './use-completion'; -export * from './use-assistant'; +export * from './use-object'; diff --git a/packages/react/src/use-assistant.ui.test.tsx b/packages/react/src/use-assistant.ui.test.tsx index c1bf7ffedf28..38791083918a 100644 --- a/packages/react/src/use-assistant.ui.test.tsx +++ b/packages/react/src/use-assistant.ui.test.tsx @@ -18,7 +18,7 @@ describe('stream data stream', () => {
{status}
{messages.map((m, idx) => ( -
+
{m.role === 'user' ? 'User: ' : 'AI: '} {m.content}
diff --git a/packages/react/src/use-object.ts b/packages/react/src/use-object.ts new file mode 100644 index 000000000000..70ba4b0d0e02 --- /dev/null +++ b/packages/react/src/use-object.ts @@ -0,0 +1,123 @@ +import { + DeepPartial, + isDeepEqualData, + parsePartialJson, +} from '@ai-sdk/ui-utils'; +import { useId, useState } from 'react'; +import useSWR from 'swr'; +import z from 'zod'; + +export type Experimental_UseObjectOptions = { + /** + * The API endpoint. It should stream JSON that matches the schema as chunked text. + */ + api: string; + + /** + * A Zod schema that defines the shape of the complete object. + */ + schema: z.Schema; + + /** + * An unique identifier. If not provided, a random one will be + * generated. When provided, the `useObject` hook with the same `id` will + * have shared states across components. + */ + id?: string; + + /** + * An optional value for the initial object. + */ + initialValue?: DeepPartial; +}; + +export type Experimental_UseObjectHelpers = { + /** + * Calls the API with the provided input as JSON body. + */ + setInput: (input: INPUT) => void; + + /** + * The current value for the generated object. Updated as the API streams JSON chunks. + */ + object: DeepPartial | undefined; + + /** + * The error object of the API request if any. + */ + error: undefined | unknown; +}; + +function useObject({ + api, + id, + schema, // required, in the future we will use it for validation + initialValue, +}: Experimental_UseObjectOptions): Experimental_UseObjectHelpers< + RESULT, + INPUT +> { + // Generate an unique id if not provided. + const hookId = useId(); + const completionId = id ?? hookId; + + // Store the completion state in SWR, using the completionId as the key to share states. + const { data, mutate } = useSWR>( + [api, completionId], + null, + { fallbackData: initialValue }, + ); + + const [error, setError] = useState(undefined); + + return { + async setInput(input) { + try { + const response = await fetch(api, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + throw new Error( + (await response.text()) ?? 'Failed to fetch the response.', + ); + } + + if (response.body == null) { + throw new Error('The response body is empty.'); + } + + let accumulatedText = ''; + let latestObject: DeepPartial | undefined = undefined; + + response.body!.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + accumulatedText += chunk; + + const currentObject = parsePartialJson( + accumulatedText, + ) as DeepPartial; + + if (!isDeepEqualData(latestObject, currentObject)) { + latestObject = currentObject; + + mutate(currentObject); + } + }, + }), + ); + + setError(undefined); + } catch (error) { + setError(error); + } + }, + object: data, + error, + }; +} + +export const experimental_useObject = useObject; diff --git a/packages/react/src/use-object.ui.test.tsx b/packages/react/src/use-object.ui.test.tsx new file mode 100644 index 000000000000..52432e64d945 --- /dev/null +++ b/packages/react/src/use-object.ui.test.tsx @@ -0,0 +1,79 @@ +import { mockFetchDataStream, mockFetchError } from '@ai-sdk/ui-utils/test'; +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { z } from 'zod'; +import { experimental_useObject } from './use-object'; + +describe('text stream', () => { + const TestComponent = () => { + const { object, error, setInput } = experimental_useObject({ + api: '/api/use-object', + schema: z.object({ content: z.string() }), + }); + + return ( +
+
{JSON.stringify(object)}
+
{error?.toString()}
+ +
+ ); + }; + + beforeEach(() => { + render(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + describe("when the API returns 'Hello, world!'", () => { + let mockFetch: ReturnType; + + beforeEach(async () => { + mockFetch = mockFetchDataStream({ + url: 'https://example.com/api/use-object', + chunks: ['{ ', '"content": "Hello, ', 'world', '!"'], + }); + + await userEvent.click(screen.getByTestId('submit-button')); + }); + + it('should render stream', async () => { + await screen.findByTestId('object'); + expect(screen.getByTestId('object')).toHaveTextContent( + JSON.stringify({ content: 'Hello, world!' }), + ); + }); + + it("should send 'test' to the API", async () => { + expect(await mockFetch.requestBody).toBe(JSON.stringify('test-input')); + }); + + it('should not have an error', async () => { + await screen.findByTestId('error'); + expect(screen.getByTestId('error')).toBeEmptyDOMElement(); + }); + }); + + describe('when the API returns a 404', () => { + beforeEach(async () => { + mockFetchError({ statusCode: 404, errorMessage: 'Not found' }); + + await userEvent.click(screen.getByTestId('submit-button')); + }); + + it('should render error', async () => { + await screen.findByTestId('error'); + expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found'); + }); + }); +}); diff --git a/packages/ui-utils/package.json b/packages/ui-utils/package.json index 19e24c58ce92..298262373b37 100644 --- a/packages/ui-utils/package.json +++ b/packages/ui-utils/package.json @@ -36,14 +36,24 @@ } }, "dependencies": { - "@ai-sdk/provider-utils": "0.0.15" + "@ai-sdk/provider-utils": "0.0.15", + "secure-json-parse": "2.7.0" }, "devDependencies": { "@types/react": "^18", "@types/node": "^18", "@vercel/ai-tsconfig": "workspace:*", "tsup": "^8", - "typescript": "5.1.3" + "typescript": "5.1.3", + "zod": "3.23.8" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } }, "engines": { "node": ">=18" diff --git a/packages/core/core/util/deep-partial.ts b/packages/ui-utils/src/deep-partial.ts similarity index 100% rename from packages/core/core/util/deep-partial.ts rename to packages/ui-utils/src/deep-partial.ts diff --git a/packages/core/core/util/fix-json.test.ts b/packages/ui-utils/src/fix-json.test.ts similarity index 100% rename from packages/core/core/util/fix-json.test.ts rename to packages/ui-utils/src/fix-json.test.ts diff --git a/packages/core/core/util/fix-json.ts b/packages/ui-utils/src/fix-json.ts similarity index 100% rename from packages/core/core/util/fix-json.ts rename to packages/ui-utils/src/fix-json.ts diff --git a/packages/ui-utils/src/index.ts b/packages/ui-utils/src/index.ts index 0742bec52004..515450efdcd2 100644 --- a/packages/ui-utils/src/index.ts +++ b/packages/ui-utils/src/index.ts @@ -7,7 +7,10 @@ export { generateId } from '@ai-sdk/provider-utils'; export { callChatApi } from './call-chat-api'; export { callCompletionApi } from './call-completion-api'; export { createChunkDecoder } from './create-chunk-decoder'; +export type { DeepPartial } from './deep-partial'; +export { isDeepEqualData } from './is-deep-equal-data'; export { parseComplexResponse } from './parse-complex-response'; +export { parsePartialJson } from './parse-partial-json'; export { processChatStream } from './process-chat-stream'; export { readDataStream } from './read-data-stream'; export { formatStreamPart, parseStreamPart } from './stream-parts'; diff --git a/packages/core/core/util/is-deep-equal-data.test.ts b/packages/ui-utils/src/is-deep-equal-data.test.ts similarity index 100% rename from packages/core/core/util/is-deep-equal-data.test.ts rename to packages/ui-utils/src/is-deep-equal-data.test.ts diff --git a/packages/core/core/util/is-deep-equal-data.ts b/packages/ui-utils/src/is-deep-equal-data.ts similarity index 100% rename from packages/core/core/util/is-deep-equal-data.ts rename to packages/ui-utils/src/is-deep-equal-data.ts diff --git a/packages/core/core/util/parse-partial-json.ts b/packages/ui-utils/src/parse-partial-json.ts similarity index 100% rename from packages/core/core/util/parse-partial-json.ts rename to packages/ui-utils/src/parse-partial-json.ts diff --git a/packages/ui-utils/src/test/mock-fetch.ts b/packages/ui-utils/src/test/mock-fetch.ts index e8929c6403ac..e1ed742dafa2 100644 --- a/packages/ui-utils/src/test/mock-fetch.ts +++ b/packages/ui-utils/src/test/mock-fetch.ts @@ -77,18 +77,15 @@ export function mockFetchDataStreamWithGenerator({ ok: true, status: 200, bodyUsed: false, - body: { - getReader() { - return { - read() { - return Promise.resolve(chunkGenerator.next()); - }, - releaseLock() {}, - cancel() {}, - }; + body: new ReadableStream({ + async start(controller) { + for await (const chunk of chunkGenerator) { + controller.enqueue(chunk); + } + controller.close(); }, - }, - } as unknown as Response; + }), + } as Response; }); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa6aa98137b3..dad06728f862 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1381,6 +1381,9 @@ importers: typescript: specifier: 5.1.3 version: 5.1.3 + zod: + specifier: 3.23.8 + version: 3.23.8 packages/solid: dependencies: @@ -1476,6 +1479,9 @@ importers: '@ai-sdk/provider-utils': specifier: 0.0.15 version: link:../provider-utils + secure-json-parse: + specifier: 2.7.0 + version: 2.7.0 devDependencies: '@types/node': specifier: ^18 @@ -1492,6 +1498,9 @@ importers: typescript: specifier: 5.1.3 version: 5.1.3 + zod: + specifier: 3.23.8 + version: 3.23.8 packages/vue: dependencies: