📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
---
|
||||
name: ai-sdk
|
||||
description: This skill should be used when building AI features with Vercel AI SDK, using useChat, streamText, or generateObject, or when "AI SDK", "streaming chat", or "structured outputs" are mentioned.
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Vercel AI SDK v6
|
||||
|
||||
Patterns for building AI-powered applications with the Vercel AI SDK v6.
|
||||
|
||||
<when_to_use>
|
||||
|
||||
- Building streaming chat UIs
|
||||
- Structured JSON outputs with Zod schemas
|
||||
- Multi-step agent workflows with tools
|
||||
- Tool approval flows (human-in-the-loop)
|
||||
- Next.js App Router integrations
|
||||
|
||||
</when_to_use>
|
||||
|
||||
## Version Guard
|
||||
|
||||
Target **AI SDK 6.x** APIs. Default packages:
|
||||
|
||||
```
|
||||
ai@^6
|
||||
@ai-sdk/react@^2
|
||||
@ai-sdk/openai@^2 (or @ai-sdk/anthropic, etc.)
|
||||
zod@^3
|
||||
```
|
||||
|
||||
**Avoid v4/v5 holdovers:**
|
||||
- `StreamingTextResponse` → use `result.toUIMessageStreamResponse()`
|
||||
- Legacy `Message` shape → use `UIMessage`
|
||||
- Input-managed `useChat` → use transport-based pattern
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Message Types
|
||||
|
||||
| Type | Purpose | When to Use |
|
||||
|------|---------|-------------|
|
||||
| `UIMessage` | User-facing, persistence | Store in database, render in UI |
|
||||
| `ModelMessage` | LLM-compatible | Convert at call sites only |
|
||||
|
||||
**Rule:** Persist `UIMessage[]`. Convert to `ModelMessage[]` only when calling the model.
|
||||
|
||||
### Streaming Patterns
|
||||
|
||||
| Function | Use Case |
|
||||
|----------|----------|
|
||||
| `streamText` | Streaming text responses |
|
||||
| `generateText` | Non-streaming text |
|
||||
| `streamObject` | Streaming JSON with partial updates |
|
||||
| `generateObject` | Non-streaming JSON |
|
||||
| `ToolLoopAgent` | Multi-step agent with tools |
|
||||
|
||||
## Golden Path: Streaming Chat
|
||||
|
||||
### API Route (App Router)
|
||||
|
||||
```typescript
|
||||
// app/api/chat/route.ts
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { streamText, convertToModelMessages, type UIMessage } from 'ai';
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
getErrorMessage: (e) =>
|
||||
e instanceof Error ? e.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client Hook
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useChat, type UIMessage } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
|
||||
export function Chat({ initialMessages = [] }: { initialMessages?: UIMessage[] }) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const { messages, sendMessage, status, error } = useChat({
|
||||
messages: initialMessages,
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
sendMessage({ role: 'user', content: [{ type: 'text', text: input }] });
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id}>
|
||||
<b>{m.role === 'user' ? 'You' : 'AI'}:</b>
|
||||
{m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))}
|
||||
</div>
|
||||
))}
|
||||
{status === 'error' && <div className="text-red-600">{error?.message}</div>}
|
||||
<form onSubmit={submit}>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Structured Outputs
|
||||
|
||||
```typescript
|
||||
import { generateObject, streamObject } from 'ai';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
recipe: z.object({
|
||||
name: z.string(),
|
||||
ingredients: z.array(z.string()),
|
||||
steps: z.array(z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
// One-shot JSON
|
||||
const { object } = await generateObject({
|
||||
model: openai('gpt-4o'),
|
||||
schema,
|
||||
prompt: 'Generate a lasagna recipe.',
|
||||
});
|
||||
|
||||
// Streaming JSON (partial updates)
|
||||
const { partialObjectStream } = streamObject({
|
||||
model: openai('gpt-4o'),
|
||||
schema,
|
||||
prompt: 'Generate a lasagna recipe.',
|
||||
});
|
||||
|
||||
for await (const partial of partialObjectStream) {
|
||||
// Render progressively
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Server-Side Tool Definition
|
||||
|
||||
```typescript
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const searchTool = tool({
|
||||
description: 'Search product catalog',
|
||||
inputSchema: z.object({ query: z.string() }),
|
||||
execute: async ({ query }) => {
|
||||
// Implementation
|
||||
return [{ id: 'p1', name: 'Example Product' }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Step Tool Loops
|
||||
|
||||
```typescript
|
||||
import { streamText, stepCountIs } from 'ai';
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
tools: { search: searchTool },
|
||||
stopWhen: stepCountIs(6), // Max 6 iterations
|
||||
prepareStep: async ({ stepNumber, messages }) =>
|
||||
messages.length > 10 ? { messages: messages.slice(-10) } : {},
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
```
|
||||
|
||||
## v6: ToolLoopAgent
|
||||
|
||||
First-class agent abstraction for autonomous multi-step workflows.
|
||||
|
||||
### Basic Agent
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from 'ai';
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
instructions: 'You are a helpful research assistant.',
|
||||
tools: {
|
||||
search: searchTool,
|
||||
calculator: calculatorTool,
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
// Non-streaming
|
||||
const result = await agent.generate({
|
||||
prompt: 'What is the weather in NYC?',
|
||||
});
|
||||
console.log(result.text);
|
||||
console.log(result.steps); // All steps taken
|
||||
|
||||
// Streaming
|
||||
const stream = agent.stream({ prompt: 'Research quantum computing.' });
|
||||
for await (const chunk of stream.textStream) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
### Agent with UI Streaming
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, createAgentUIStream } from 'ai';
|
||||
|
||||
const agent = new ToolLoopAgent({ model, instructions, tools });
|
||||
|
||||
const stream = await createAgentUIStream({
|
||||
agent,
|
||||
messages: [{ role: 'user', content: 'What is the weather?' }],
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// UI message chunks
|
||||
}
|
||||
```
|
||||
|
||||
## v6: Tool Approval (Human-in-the-Loop)
|
||||
|
||||
### Static Approval (Always Require)
|
||||
|
||||
```typescript
|
||||
const dangerousTool = tool({
|
||||
description: 'Delete user data',
|
||||
inputSchema: z.object({ userId: z.string() }),
|
||||
needsApproval: true, // Always require approval
|
||||
execute: async ({ userId }) => {
|
||||
return await deleteUserData(userId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Approval (Conditional)
|
||||
|
||||
```typescript
|
||||
const paymentTool = tool({
|
||||
description: 'Process payment',
|
||||
inputSchema: z.object({
|
||||
amount: z.number(),
|
||||
recipient: z.string(),
|
||||
}),
|
||||
needsApproval: async ({ amount }) => amount > 1000, // Only large transactions
|
||||
execute: async ({ amount, recipient }) => {
|
||||
return await processPayment(amount, recipient);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Client-Side Approval UI
|
||||
|
||||
```tsx
|
||||
function ToolApprovalView({ invocation, addToolApprovalResponse }) {
|
||||
if (invocation.state === 'approval-requested') {
|
||||
return (
|
||||
<div>
|
||||
<p>Approve action: {invocation.input.description}?</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
addToolApprovalResponse({ id: invocation.approval.id, approved: true })
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
addToolApprovalResponse({ id: invocation.approval.id, approved: false })
|
||||
}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (invocation.state === 'output-available') {
|
||||
return <div>Result: {JSON.stringify(invocation.output)}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Persistence
|
||||
|
||||
```typescript
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
|
||||
onFinish: async ({ messages: complete }) => {
|
||||
await saveChat({ chatId, messages: complete }); // Persist UIMessage[]
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// Server: Surface errors to client
|
||||
return result.toUIMessageStreamResponse({
|
||||
getErrorMessage: (e) =>
|
||||
e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e),
|
||||
});
|
||||
|
||||
// Client: Handle error state
|
||||
const { status, error } = useChat({ ... });
|
||||
if (status === 'error') {
|
||||
return <div>Error: {error?.message}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Avoid | Use Instead |
|
||||
|-------|-------------|
|
||||
| `StreamingTextResponse` | `result.toUIMessageStreamResponse()` |
|
||||
| Persisting `ModelMessage` | Persist `UIMessage[]` |
|
||||
| Unbounded tool loops | `stopWhen: stepCountIs(N)` |
|
||||
| Client-only state for long sessions | Add persistence + resumable streams |
|
||||
| `any` types | Zod schemas + typed `UIMessage` |
|
||||
|
||||
<references>
|
||||
|
||||
- [agents.md](references/agents.md) - ToolLoopAgent patterns and workflows
|
||||
- [tool-approval.md](references/tool-approval.md) - Human-in-the-loop approval flows
|
||||
- [persistence.md](references/persistence.md) - Chat persistence strategies
|
||||
|
||||
</references>
|
||||
@@ -0,0 +1,184 @@
|
||||
# ToolLoopAgent Patterns
|
||||
|
||||
Deep dive on v6 agent workflows.
|
||||
|
||||
## When to Use ToolLoopAgent
|
||||
|
||||
| Use Case | Approach |
|
||||
|----------|----------|
|
||||
| Single tool call | `streamText` with tools |
|
||||
| Multi-step reasoning | `ToolLoopAgent` |
|
||||
| Autonomous workflows | `ToolLoopAgent` with `stopWhen` |
|
||||
| Complex orchestration | `ToolLoopAgent` with custom stop conditions |
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from 'ai';
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
// Required
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
|
||||
// Optional
|
||||
instructions: 'You are a research assistant.',
|
||||
tools: { search, calculate, summarize },
|
||||
|
||||
// Stop conditions
|
||||
stopWhen: stepCountIs(10),
|
||||
// Or custom: async ({ steps }) => steps.length >= 10
|
||||
|
||||
// Tool selection
|
||||
toolChoice: 'auto', // 'auto' | 'required' | 'none'
|
||||
|
||||
// Token limits
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
```
|
||||
|
||||
## Execution Patterns
|
||||
|
||||
### Non-Streaming (Simple)
|
||||
|
||||
```typescript
|
||||
const result = await agent.generate({
|
||||
prompt: 'Research quantum computing breakthroughs.',
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
console.log(result.steps); // Array of all steps
|
||||
console.log(result.steps.length, 'steps executed');
|
||||
```
|
||||
|
||||
### Streaming (Real-time)
|
||||
|
||||
```typescript
|
||||
const stream = agent.stream({
|
||||
prompt: 'Analyze this data and provide insights.',
|
||||
});
|
||||
|
||||
for await (const chunk of stream.textStream) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
### UI Streaming (React/Next.js)
|
||||
|
||||
```typescript
|
||||
import { createAgentUIStream } from 'ai';
|
||||
|
||||
const stream = await createAgentUIStream({
|
||||
agent,
|
||||
messages: [{ role: 'user', content: 'What is the weather?' }],
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Yield to client
|
||||
}
|
||||
```
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
### Built-in: Step Count
|
||||
|
||||
```typescript
|
||||
import { stepCountIs } from 'ai';
|
||||
|
||||
stopWhen: stepCountIs(5) // Stop after 5 steps
|
||||
```
|
||||
|
||||
### Custom: Finish Reason
|
||||
|
||||
```typescript
|
||||
stopWhen: async ({ steps }) =>
|
||||
steps.at(-1)?.finishReason === 'stop'
|
||||
```
|
||||
|
||||
### Custom: Combined
|
||||
|
||||
```typescript
|
||||
stopWhen: async ({ steps }) =>
|
||||
steps.length >= 10 || steps.at(-1)?.finishReason === 'stop'
|
||||
```
|
||||
|
||||
## Tool Choice Control
|
||||
|
||||
```typescript
|
||||
const agent = new ToolLoopAgent({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
tools: { search, calculate },
|
||||
|
||||
// Force tool use every step
|
||||
toolChoice: 'required',
|
||||
|
||||
// Disable tools (text only)
|
||||
toolChoice: 'none',
|
||||
|
||||
// Let model decide (default)
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
```
|
||||
|
||||
## Agent with Constraints
|
||||
|
||||
```typescript
|
||||
const customerSupportAgent = new ToolLoopAgent({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
instructions: `You are a customer support specialist.
|
||||
|
||||
Rules:
|
||||
- Never promise refunds without checking policy
|
||||
- Always be empathetic and professional
|
||||
- If unsure, offer to escalate
|
||||
- Keep responses concise
|
||||
- Never share internal company information`,
|
||||
tools: {
|
||||
checkOrderStatus,
|
||||
lookupPolicy,
|
||||
createTicket,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Accessing Step History
|
||||
|
||||
```typescript
|
||||
const result = await agent.generate({ prompt: '...' });
|
||||
|
||||
for (const step of result.steps) {
|
||||
if (step.type === 'tool-call') {
|
||||
console.log(`Called ${step.tool} with`, step.input);
|
||||
} else if (step.type === 'text-generation') {
|
||||
console.log('Generated:', step.output);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await agent.generate({ prompt: '...' });
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Agent execution aborted');
|
||||
} else {
|
||||
console.error('Agent error:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**DO:**
|
||||
- Set reasonable `stopWhen` limits
|
||||
- Use typed tool schemas with Zod
|
||||
- Handle abort signals for long-running agents
|
||||
- Log step history for debugging
|
||||
|
||||
**DON'T:**
|
||||
- Leave agents unbounded (no stop condition)
|
||||
- Use synchronous blocking operations in tools
|
||||
- Ignore error states
|
||||
- Skip tool validation
|
||||
@@ -0,0 +1,259 @@
|
||||
# Chat Persistence
|
||||
|
||||
Strategies for persisting chat messages with AI SDK v6.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Persist `UIMessage[]`, convert to `ModelMessage[]` only at call sites.**
|
||||
|
||||
```typescript
|
||||
// Database stores UIMessage format
|
||||
const chat = await loadChat(chatId); // Returns UIMessage[]
|
||||
|
||||
// Convert only when calling the model
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(chat.messages),
|
||||
});
|
||||
```
|
||||
|
||||
## Server-Side Persistence
|
||||
|
||||
### onFinish Callback
|
||||
|
||||
```typescript
|
||||
import { streamText, createIdGenerator } from 'ai';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages, chatId } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
|
||||
// Generate stable, server-side message IDs
|
||||
generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
|
||||
|
||||
// Persist after stream completes
|
||||
onFinish: async ({ messages: completeMessages }) => {
|
||||
await db.chat.upsert({
|
||||
where: { id: chatId },
|
||||
update: { messages: completeMessages, updatedAt: new Date() },
|
||||
create: { id: chatId, messages: completeMessages },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Survive Client Disconnects
|
||||
|
||||
Call `consumeStream()` to ensure the stream completes even if the client disconnects:
|
||||
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
// Start consuming immediately (runs in background)
|
||||
result.consumeStream();
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
onFinish: async ({ messages }) => {
|
||||
await saveChat(chatId, messages);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Drizzle Example
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, jsonb, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const chats = pgTable('chats', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
title: text('title'),
|
||||
messages: jsonb('messages').$type<UIMessage[]>().notNull().default([]),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### Prisma Example
|
||||
|
||||
```prisma
|
||||
model Chat {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String?
|
||||
messages Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
## Loading Chats
|
||||
|
||||
```typescript
|
||||
// API route to load chat
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
const chat = await db.chat.findUnique({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: chat.id,
|
||||
messages: chat.messages as UIMessage[],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Client-side loading
|
||||
'use client';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function Chat({ chatId }: { chatId: string }) {
|
||||
const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/chats/${chatId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setInitialMessages(data.messages);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [chatId]);
|
||||
|
||||
const { messages, sendMessage } = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return <ChatUI messages={messages} onSend={sendMessage} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Optimization
|
||||
|
||||
Send only the last message, load history server-side:
|
||||
|
||||
```typescript
|
||||
// Client: Send only new message
|
||||
const { sendMessage } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
prepareSendMessagesRequest: ({ id, messages }) => ({
|
||||
body: {
|
||||
chatId: id,
|
||||
message: messages.at(-1) // Only send last message
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Server: Load history from database
|
||||
export async function POST(req: Request) {
|
||||
const { chatId, message } = await req.json();
|
||||
|
||||
// Load existing messages from database
|
||||
const chat = await db.chat.findUnique({ where: { id: chatId } });
|
||||
const messages = [...(chat?.messages ?? []), message];
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
onFinish: async ({ messages }) => {
|
||||
await db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Resumable Streams
|
||||
|
||||
Resume interrupted streams on page reload:
|
||||
|
||||
```tsx
|
||||
function Chat({ chatId }: { chatId: string }) {
|
||||
const { messages, resumeStream, status } = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
// Resume on mount if there's an incomplete stream
|
||||
useEffect(() => {
|
||||
const lastMessage = messages.at(-1);
|
||||
if (lastMessage?.role === 'assistant' && status === 'ready') {
|
||||
// Check if message seems incomplete
|
||||
resumeStream();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ... */}
|
||||
{status === 'streaming' && <div>AI is typing...</div>}
|
||||
<button onClick={() => resumeStream()}>Resume</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from v4/v5
|
||||
|
||||
If migrating existing data, use the dual-write pattern:
|
||||
|
||||
1. Create new `messages_v6` table
|
||||
2. Dual-write to both tables
|
||||
3. Run background migration
|
||||
4. Switch reads to v6 schema
|
||||
5. Remove dual-write
|
||||
6. Drop old table
|
||||
|
||||
See [AI SDK Migration Guide](https://sdk.vercel.ai/docs/migration-guides) for detailed steps.
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Persistence:**
|
||||
- Always use `onFinish` for reliable persistence
|
||||
- Generate server-side message IDs for consistency
|
||||
- Use `consumeStream()` to complete streams even on disconnect
|
||||
|
||||
**Performance:**
|
||||
- Index by userId for user-specific queries
|
||||
- Consider pagination for long conversations
|
||||
- Use bandwidth optimization for mobile clients
|
||||
|
||||
**Reliability:**
|
||||
- Handle concurrent updates with optimistic locking
|
||||
- Implement retry logic for database failures
|
||||
- Log persistence errors for debugging
|
||||
@@ -0,0 +1,250 @@
|
||||
# Tool Approval (Human-in-the-Loop)
|
||||
|
||||
v6 patterns for requiring user approval before tool execution.
|
||||
|
||||
## When to Use
|
||||
|
||||
| Scenario | Approval Type |
|
||||
|----------|---------------|
|
||||
| Always dangerous (delete, payment) | Static: `needsApproval: true` |
|
||||
| Conditionally risky (large amounts) | Dynamic: `needsApproval: async (args) => boolean` |
|
||||
| User preference | Dynamic based on user settings |
|
||||
|
||||
## Static Approval
|
||||
|
||||
Tool always requires approval:
|
||||
|
||||
```typescript
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const deleteUserTool = tool({
|
||||
description: 'Permanently delete a user account',
|
||||
inputSchema: z.object({
|
||||
userId: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
needsApproval: true, // Always require
|
||||
execute: async ({ userId, reason }) => {
|
||||
await deleteUser(userId, reason);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Approval
|
||||
|
||||
Approval based on input:
|
||||
|
||||
```typescript
|
||||
const paymentTool = tool({
|
||||
description: 'Process a payment',
|
||||
inputSchema: z.object({
|
||||
amount: z.number(),
|
||||
recipient: z.string(),
|
||||
currency: z.string().default('USD'),
|
||||
}),
|
||||
needsApproval: async ({ amount }) => amount > 1000,
|
||||
execute: async ({ amount, recipient, currency }) => {
|
||||
return await processPayment(amount, recipient, currency);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Complex Approval Logic
|
||||
|
||||
```typescript
|
||||
const externalApiTool = tool({
|
||||
description: 'Call external API',
|
||||
inputSchema: z.object({
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'DELETE']),
|
||||
body: z.any().optional(),
|
||||
}),
|
||||
needsApproval: async ({ method, endpoint }) => {
|
||||
// Approve all non-GET requests
|
||||
if (method !== 'GET') return true;
|
||||
|
||||
// Approve requests to sensitive endpoints
|
||||
if (endpoint.includes('/admin')) return true;
|
||||
|
||||
// No approval needed for safe reads
|
||||
return false;
|
||||
},
|
||||
execute: async ({ endpoint, method, body }) => {
|
||||
return await fetch(endpoint, { method, body: JSON.stringify(body) });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Client-Side Handling
|
||||
|
||||
### useChat with Approval
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
|
||||
function Chat() {
|
||||
const { messages, sendMessage, addToolApprovalResponse } = useChat({
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
onApprove={(id) => addToolApprovalResponse({ id, approved: true })}
|
||||
onDeny={(id) => addToolApprovalResponse({ id, approved: false })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Approval UI Component
|
||||
|
||||
```tsx
|
||||
function ToolInvocation({ invocation, onApprove, onDeny }) {
|
||||
switch (invocation.state) {
|
||||
case 'approval-requested':
|
||||
return (
|
||||
<div className="border rounded p-4 bg-yellow-50">
|
||||
<h4 className="font-bold">Approval Required</h4>
|
||||
<p>Tool: {invocation.toolName}</p>
|
||||
<pre className="text-sm bg-gray-100 p-2 rounded">
|
||||
{JSON.stringify(invocation.input, null, 2)}
|
||||
</pre>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => onApprove(invocation.approval.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeny(invocation.approval.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pending':
|
||||
return <div className="text-gray-500">Waiting for tool execution...</div>;
|
||||
|
||||
case 'output-available':
|
||||
return (
|
||||
<div className="border rounded p-4 bg-green-50">
|
||||
<h4 className="font-bold">Tool Result</h4>
|
||||
<pre className="text-sm">{JSON.stringify(invocation.output, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Tool Parts in Messages
|
||||
|
||||
```tsx
|
||||
function Message({ message, onApprove, onDeny }) {
|
||||
return (
|
||||
<div>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return <p key={i}>{part.text}</p>;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-invocation') {
|
||||
return (
|
||||
<ToolInvocation
|
||||
key={i}
|
||||
invocation={part}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Processing
|
||||
|
||||
For complex approval workflows with server-side tool execution:
|
||||
|
||||
```typescript
|
||||
import { processToolCalls } from './tool-processor';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
// Check for pending approvals
|
||||
const lastMessage = messages.at(-1);
|
||||
const hasPendingApprovals = lastMessage?.parts?.some(
|
||||
(p) => p.type === 'tool-invocation' && p.state === 'output-available'
|
||||
);
|
||||
|
||||
if (hasPendingApprovals) {
|
||||
// Process approved tools
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const processed = await processToolCalls({
|
||||
writer,
|
||||
messages,
|
||||
tools: myTools,
|
||||
}, toolExecuteFunctions);
|
||||
|
||||
// Continue conversation with tool results
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(processed),
|
||||
});
|
||||
|
||||
writer.merge(result.toUIMessageStream());
|
||||
},
|
||||
originalMessages: messages,
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
|
||||
// Normal flow
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
tools: myTools,
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({ originalMessages: messages });
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Security:**
|
||||
- Always require approval for destructive operations
|
||||
- Use dynamic approval for operations with varying risk levels
|
||||
- Log all approval decisions for audit trails
|
||||
|
||||
**UX:**
|
||||
- Show clear context for what the tool will do
|
||||
- Display input parameters so users can make informed decisions
|
||||
- Provide cancel/timeout options for pending approvals
|
||||
|
||||
**Error Handling:**
|
||||
- Handle denied approvals gracefully
|
||||
- Provide alternative actions when tools are denied
|
||||
- Don't retry denied tools automatically
|
||||
Reference in New Issue
Block a user