diff --git a/docs/technical/structured-data.md b/docs/technical/structured-data.md new file mode 100644 index 0000000..a622405 --- /dev/null +++ b/docs/technical/structured-data.md @@ -0,0 +1,410 @@ + +# Generating Structured Data + +While text generation can be useful, your use case will likely call for generating structured data. +For example, you might want to extract information from text, classify data, or generate synthetic data. + +Many language models are capable of generating structured data, often defined as using "JSON modes" or "tools". +However, you need to manually provide schemas and then validate the generated data as LLMs can produce incorrect or incomplete structured data. + +The AI SDK standardises structured object generation across model providers +with the [`generateObject`](/docs/reference/ai-sdk-core/generate-object) +and [`streamObject`](/docs/reference/ai-sdk-core/stream-object) functions. +You can use both functions with different output strategies, e.g. `array`, `object`, `enum`, or `no-schema`, +and with different generation modes, e.g. `auto`, `tool`, or `json`. +You can use [Zod schemas](/docs/reference/ai-sdk-core/zod-schema), [Valibot](/docs/reference/ai-sdk-core/valibot-schema), or [JSON schemas](/docs/reference/ai-sdk-core/json-schema) to specify the shape of the data that you want, +and the AI model will generate data that conforms to that structure. + + + You can pass Zod objects directly to the AI SDK functions or use the + `zodSchema` helper function. + + +## Generate Object + +The `generateObject` generates structured data from a prompt. +The schema is also used to validate the generated data, ensuring type safety and correctness. + +```ts +import { generateObject } from 'ai'; +import { z } from 'zod'; + +const { object } = await generateObject({ + model: 'openai/gpt-4.1', + schema: z.object({ + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.object({ name: z.string(), amount: z.string() })), + steps: z.array(z.string()), + }), + }), + prompt: 'Generate a lasagna recipe.', +}); +``` + + + See `generateObject` in action with [these examples](#more-examples) + + +### Accessing response headers & body + +Sometimes you need access to the full response from the model provider, +e.g. to access some provider-specific headers or body content. + +You can access the raw response headers and body using the `response` property: + +```ts +import { generateObject } from 'ai'; + +const result = await generateObject({ + // ... +}); + +console.log(JSON.stringify(result.response.headers, null, 2)); +console.log(JSON.stringify(result.response.body, null, 2)); +``` + +## Stream Object + +Given the added complexity of returning structured data, model response time can be unacceptable for your interactive use case. +With the [`streamObject`](/docs/reference/ai-sdk-core/stream-object) function, you can stream the model's response as it is generated. + +```ts +import { streamObject } from 'ai'; + +const { partialObjectStream } = streamObject({ + // ... +}); + +// use partialObjectStream as an async iterable +for await (const partialObject of partialObjectStream) { + console.log(partialObject); +} +``` + +You can use `streamObject` to stream generated UIs in combination with React Server Components (see [Generative UI](../ai-sdk-rsc))) or the [`useObject`](/docs/reference/ai-sdk-ui/use-object) hook. + +See `streamObject` in action with [these examples](#more-examples) + +### `onError` callback + +`streamObject` immediately starts streaming. +Errors become part of the stream and are not thrown to prevent e.g. servers from crashing. + +To log errors, you can provide an `onError` callback that is triggered when an error occurs. + +```tsx highlight="5-7" +import { streamObject } from 'ai'; + +const result = streamObject({ + // ... + onError({ error }) { + console.error(error); // your error logging logic here + }, +}); +``` + +## Output Strategy + +You can use both functions with different output strategies, e.g. `array`, `object`, `enum`, or `no-schema`. + +### Object + +The default output strategy is `object`, which returns the generated data as an object. +You don't need to specify the output strategy if you want to use the default. + +### Array + +If you want to generate an array of objects, you can set the output strategy to `array`. +When you use the `array` output strategy, the schema specifies the shape of an array element. +With `streamObject`, you can also stream the generated array elements using `elementStream`. + +```ts highlight="7,18" +import { openai } from '@ai-sdk/openai'; +import { streamObject } from 'ai'; +import { z } from 'zod'; + +const { elementStream } = streamObject({ + model: openai('gpt-4.1'), + output: 'array', + schema: z.object({ + name: z.string(), + class: z + .string() + .describe('Character class, e.g. warrior, mage, or thief.'), + description: z.string(), + }), + prompt: 'Generate 3 hero descriptions for a fantasy role playing game.', +}); + +for await (const hero of elementStream) { + console.log(hero); +} +``` + +### Enum + +If you want to generate a specific enum value, e.g. for classification tasks, +you can set the output strategy to `enum` +and provide a list of possible values in the `enum` parameter. + +Enum output is only available with `generateObject`. + +```ts highlight="5-6" +import { generateObject } from 'ai'; + +const { object } = await generateObject({ + model: 'openai/gpt-4.1', + output: 'enum', + enum: ['action', 'comedy', 'drama', 'horror', 'sci-fi'], + prompt: + 'Classify the genre of this movie plot: ' + + '"A group of astronauts travel through a wormhole in search of a ' + + 'new habitable planet for humanity."', +}); +``` + +### No Schema + +In some cases, you might not want to use a schema, +for example when the data is a dynamic user request. +You can use the `output` setting to set the output format to `no-schema` in those cases +and omit the schema parameter. + +```ts highlight="6" +import { openai } from '@ai-sdk/openai'; +import { generateObject } from 'ai'; + +const { object } = await generateObject({ + model: openai('gpt-4.1'), + output: 'no-schema', + prompt: 'Generate a lasagna recipe.', +}); +``` + +## Schema Name and Description + +You can optionally specify a name and description for the schema. These are used by some providers for additional LLM guidance, e.g. via tool or schema name. + +```ts highlight="6-7" +import { generateObject } from 'ai'; +import { z } from 'zod'; + +const { object } = await generateObject({ + model: 'openai/gpt-4.1', + schemaName: 'Recipe', + schemaDescription: 'A recipe for a dish.', + schema: z.object({ + name: z.string(), + ingredients: z.array(z.object({ name: z.string(), amount: z.string() })), + steps: z.array(z.string()), + }), + prompt: 'Generate a lasagna recipe.', +}); +``` + +## Accessing Reasoning + +You can access the reasoning used by the language model to generate the object via the `reasoning` property on the result. This property contains a string with the model's thought process, if available. + +```ts +import { openai, OpenAIResponsesProviderOptions } from '@ai-sdk/openai'; +import { generateObject } from 'ai'; +import { z } from 'zod'; + +const result = await generateObject({ + model: openai('gpt-5'), + schema: z.object({ + recipe: z.object({ + name: z.string(), + ingredients: z.array( + z.object({ + name: z.string(), + amount: z.string(), + }), + ), + steps: z.array(z.string()), + }), + }), + prompt: 'Generate a lasagna recipe.', + providerOptions: { + openai: { + strictJsonSchema: true, + reasoningSummary: 'detailed', + } satisfies OpenAIResponsesProviderOptions, + }, +}); + +console.log(result.reasoning); +``` + +## Error Handling + +When `generateObject` cannot generate a valid object, it throws a [`AI_NoObjectGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-object-generated-error). + +This error occurs when the AI provider fails to generate a parsable object that conforms to the schema. +It can arise due to the following reasons: + +- The model failed to generate a response. +- The model generated a response that could not be parsed. +- The model generated a response that could not be validated against the schema. + +The error preserves the following information to help you log the issue: + +- `text`: The text that was generated by the model. This can be the raw text or the tool call text, depending on the object generation mode. +- `response`: Metadata about the language model response, including response id, timestamp, and model. +- `usage`: Request token usage. +- `cause`: The cause of the error (e.g. a JSON parsing error). You can use this for more detailed error handling. + +```ts +import { generateObject, NoObjectGeneratedError } from 'ai'; + +try { + await generateObject({ model, schema, prompt }); +} catch (error) { + if (NoObjectGeneratedError.isInstance(error)) { + console.log('NoObjectGeneratedError'); + console.log('Cause:', error.cause); + console.log('Text:', error.text); + console.log('Response:', error.response); + console.log('Usage:', error.usage); + } +} +``` + +## Repairing Invalid or Malformed JSON + + + The `repairText` function is experimental and may change in the future. + + +Sometimes the model will generate invalid or malformed JSON. +You can use the `repairText` function to attempt to repair the JSON. + +It receives the error, either a `JSONParseError` or a `TypeValidationError`, +and the text that was generated by the model. +You can then attempt to repair the text and return the repaired text. + +```ts highlight="7-10" +import { generateObject } from 'ai'; + +const { object } = await generateObject({ + model, + schema, + prompt, + experimental_repairText: async ({ text, error }) => { + // example: add a closing brace to the text + return text + '}'; + }, +}); +``` + +## Structured outputs with `generateText` and `streamText` + +You can generate structured data with `generateText` and `streamText` by using the `experimental_output` setting. + + + Some models, e.g. those by OpenAI, support structured outputs and tool calling + at the same time. This is only possible with `generateText` and `streamText`. + + + + Structured output generation with `generateText` and `streamText` is + experimental and may change in the future. + + +### `generateText` + +```ts highlight="2,4-18" +// experimental_output is a structured object that matches the schema: +const { experimental_output } = await generateText({ + // ... + experimental_output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number().nullable().describe('Age of the person.'), + contact: z.object({ + type: z.literal('email'), + value: z.string(), + }), + occupation: z.object({ + type: z.literal('employed'), + company: z.string(), + position: z.string(), + }), + }), + }), + prompt: 'Generate an example person for testing.', +}); +``` + +### `streamText` + +```ts highlight="2,4-18" +// experimental_partialOutputStream contains generated partial objects: +const { experimental_partialOutputStream } = await streamText({ + // ... + experimental_output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number().nullable().describe('Age of the person.'), + contact: z.object({ + type: z.literal('email'), + value: z.string(), + }), + occupation: z.object({ + type: z.literal('employed'), + company: z.string(), + position: z.string(), + }), + }), + }), + prompt: 'Generate an example person for testing.', +}); +``` + +## More Examples + +You can see `generateObject` and `streamObject` in action using various frameworks in the following examples: + +### `generateObject` + + + +### `streamObject` + + diff --git a/src/app/api/generate-conversation/route.ts b/src/app/api/generate-conversation/route.ts index 216df90..9231e76 100644 --- a/src/app/api/generate-conversation/route.ts +++ b/src/app/api/generate-conversation/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { generateObject } from 'ai'; +import { streamObject } from 'ai'; import { mistral } from '@ai-sdk/mistral'; import { z } from 'zod'; @@ -10,11 +10,6 @@ const messageSchema = z.object({ timestamp: z.string(), }); -const conversationSchema = z.object({ - messages: z.array(messageSchema), - detectedLanguage: z.string(), -}); - export async function POST(request: NextRequest) { try { const { content, title, url } = await request.json(); @@ -26,20 +21,21 @@ export async function POST(request: NextRequest) { ); } - console.log('Generating conversation for:', { title, url, contentLength: content.length }); + console.log('Generating streaming conversation for:', { title, url, contentLength: content.length, contentPreview: content.substring(0, 200) + '...' }); - // Generate podcast conversation using Mistral - const { object } = await generateObject({ + // Stream podcast conversation using Mistral + const result = streamObject({ model: mistral('mistral-medium-latest'), - schema: conversationSchema, - schemaName: 'PodcastConversation', - schemaDescription: 'A podcast-style conversation between two hosts discussing scraped content', + output: 'array', + schema: messageSchema, + schemaName: 'PodcastMessage', + schemaDescription: 'A single message in a podcast-style conversation between two hosts', prompt: `You are generating a podcast conversation between two hosts discussing the following scraped content from "${title}" at ${url}. CONTENT: ${content} -Generate a natural, engaging podcast conversation with exactly 10 turns (5 per host). The conversation should: +Generate a natural, engaging podcast conversation with at least 20 turns (10 per host). The conversation should: 1. HOST 1 PERSONALITY: Bubbly, excited, enthusiastic, and optimistic. Uses expressions like "Wow!", "Amazing!", "That's so cool!". Often laughs [giggles] and shows genuine excitement. @@ -55,20 +51,55 @@ Generate a natural, engaging podcast conversation with exactly 10 turns (5 per h 7. The conversation should flow naturally and cover the main points of the content -Format the response as a structured object with messages array and detected language.`, +8. Create a substantial conversation that thoroughly explores the content from multiple angles + +Generate the messages one by one as an array. Each message should have: +- id: sequential number as string +- speaker: either "host1" or "host2" alternating +- text: the message content with emotional expressions in brackets +- timestamp: in MM:SS format`, temperature: 0.7, - maxTokens: 2000, + maxTokens: 4000, + onError({ error }) { + console.error('Streaming error:', error); + }, }); - console.log('Conversation generated successfully'); + // Create streaming response with Server-Sent Events + const stream = new ReadableStream({ + async start(controller) { + try { + console.log('Setting up element stream...'); + const { elementStream } = result; + console.log('Element stream created:', !!elementStream); + + let messageCount = 0; + for await (const message of elementStream) { + messageCount++; + console.log('Streaming message:', messageCount, message); + const data = `data: ${JSON.stringify(message)}\n\n`; + controller.enqueue(new TextEncoder().encode(data)); + } + + console.log('Stream completed with', messageCount, 'messages'); + + // Send completion signal + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')); + controller.close(); + } catch (error) { + console.error('Streaming error:', error); + controller.error(error); + } + }, + }); - return NextResponse.json({ - success: true, - data: { - messages: object.messages, - detectedLanguage: object.detectedLanguage, - generatedAt: new Date().toISOString() - } + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }, }); } catch (error) { diff --git a/src/app/api/scrape/route.ts b/src/app/api/scrape/route.ts index b3dc6d4..61636e4 100644 --- a/src/app/api/scrape/route.ts +++ b/src/app/api/scrape/route.ts @@ -20,16 +20,17 @@ export async function POST(request: NextRequest) { console.log('Attempting to scrape URL:', url); // Scrape the website + console.log('Attempting to scrape with Firecrawl...'); const result = await firecrawl.scrape(url, { formats: ['markdown', 'html'] }); - console.log('Firecrawl result received'); + console.log('Firecrawl result received:', JSON.stringify(result, null, 2)); // Check if we have the expected data structure if (!result || !result.markdown) { console.error('Invalid Firecrawl response:', result); - throw new Error('Invalid response from Firecrawl API'); + throw new Error(`Invalid response from Firecrawl API: ${JSON.stringify(result)}`); } // Create a truncated excerpt for display diff --git a/src/app/page.tsx b/src/app/page.tsx index 2f71fd6..725aa03 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -18,6 +18,8 @@ export default function Home() { const [url, setUrl] = useState(''); const [isLoading, setIsLoading] = useState(false); const [messages, setMessages] = useState([]); + const [visibleMessages, setVisibleMessages] = useState([]); + const scrollContainerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); @@ -33,6 +35,11 @@ export default function Home() { e.preventDefault(); setIsLoading(true); + // Clear existing conversation before starting new one + setMessages([]); + setVisibleMessages([]); + setDuration(0); + try { // Call Firecrawl API to scrape the website const response = await fetch('/api/scrape', { @@ -68,12 +75,19 @@ export default function Home() { text: 'Sorry, I encountered an error while trying to scrape the website. Please check the URL and try again.', timestamp: '0:15' }]); + setVisibleMessages([{ + id: '1', + speaker: 'host1', + text: 'Sorry, I encountered an error while trying to scrape the website. Please check the URL and try again.', + timestamp: '0:15' + }]); } finally { setIsLoading(false); } }; const generateConversation = async (content: string, title: string, url: string) => { + console.log('Starting conversation generation...'); try { const response = await fetch('/api/generate-conversation', { method: 'POST', @@ -83,18 +97,73 @@ export default function Home() { body: JSON.stringify({ content, title, url }), }); - const result = await response.json(); + console.log('Conversation API response status:', response.status); - if (result.success) { - setMessages(result.data.messages); - - // Calculate duration based on conversation length - const avgTimePerMessage = 25; // seconds - const totalDuration = result.data.messages.length * avgTimePerMessage; - setDuration(totalDuration); - } else { - throw new Error(result.error || 'Failed to generate conversation'); + if (!response.ok) { + throw new Error('Failed to generate conversation'); } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + throw new Error('No response stream available'); + } + + console.log('Starting to read stream...'); + let messageCount = 0; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('Stream reading completed. Total messages:', messageCount); + break; + } + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.slice(6); + + if (dataStr === '[DONE]') { + continue; + } + + try { + const message = JSON.parse(dataStr); + messageCount++; + console.log('Received message:', messageCount, message); + + // Add new message to the conversation + setMessages(prev => { + const updated = [...prev, message]; + + // Update duration + const avgTimePerMessage = 25; // seconds + const totalDuration = updated.length * avgTimePerMessage; + setDuration(totalDuration); + + return updated; + }); + + // Keep only first 10 messages visible initially + setVisibleMessages(prev => { + const updated = [...prev, message]; + return updated.slice(0, 10); + }); + + } catch (e) { + console.error('Error parsing streaming data:', e, dataStr); + } + } + } + } + + console.log('Conversation generation completed successfully'); + } catch (error) { console.error('Conversation generation error:', error); setMessages([{ @@ -103,6 +172,12 @@ export default function Home() { text: 'Sorry, I encountered an error while generating the podcast conversation. Please try again.', timestamp: '0:15' }]); + setVisibleMessages([{ + id: '1', + speaker: 'host1', + text: 'Sorry, I encountered an error while generating the podcast conversation. Please try again.', + timestamp: '0:15' + }]); } }; @@ -139,6 +214,32 @@ export default function Home() { } }; + // Reset visible messages when new conversation is generated + useEffect(() => { + console.log('Messages updated:', { messagesLength: messages.length, messages: messages }); + if (messages.length > 0) { + setVisibleMessages(messages.slice(0, 10)); + console.log('Set visible messages to first 10'); + } + }, [messages]); + + const handleScroll = useCallback(() => { + const element = scrollContainerRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + // More aggressive scroll detection - trigger when user scrolls past 80% of content + const scrollPercentage = scrollTop / (scrollHeight - clientHeight); + const isNearBottom = scrollPercentage > 0.8 || scrollHeight - scrollTop - clientHeight < 150; + + if (isNearBottom && visibleMessages.length < messages.length) { + // Load 5 more messages + const nextMessages = messages.slice(visibleMessages.length, visibleMessages.length + 5); + setVisibleMessages(prev => [...prev, ...nextMessages]); + } + }, [visibleMessages.length, messages]); + + return (
{/* Header */} @@ -227,13 +328,28 @@ export default function Home() { Podcast Conversation - + {messages.length === 0 ? (
-

Enter a URL and generate a podcast to see the conversation here.

+ {isLoading ? ( +
+
+

Generating new conversation...

+
+ ) : ( +

Enter a URL and generate a podcast to see the conversation here.

+ )}
) : ( - messages.map((message) => ( + visibleMessages.map((message) => (
)) )} + + {/* Scroll indicator for more messages */} + {visibleMessages.length < messages.length && ( +
+

+ Scroll down to load more messages ({visibleMessages.length}/{messages.length} shown) +

+ +
+ )}