Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:23:53 +08:00
commit caa3746d36
21 changed files with 4452 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
/**
* AI SDK UI - Custom Message Renderer
*
* Demonstrates:
* - Markdown rendering (react-markdown)
* - Code syntax highlighting (react-syntax-highlighter)
* - Custom message components
* - Copy code button
* - Timestamp display
* - User avatars
*
* Dependencies:
* npm install react-markdown react-syntax-highlighter
* npm install --save-dev @types/react-syntax-highlighter
*
* Usage:
* 1. Install dependencies
* 2. Copy this component
* 3. Use <MessageRenderer message={message} /> in your chat
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent } from 'react';
import type { Message } from 'ai';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
// Custom message renderer component
function MessageRenderer({ message }: { message: Message }) {
const [copied, setCopied] = useState(false);
const copyCode = (code: string) => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[75%] rounded-lg p-4 ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white border shadow-sm'
}`}
>
{/* Avatar & name */}
<div className="flex items-center space-x-2 mb-2">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
message.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
{message.role === 'user' ? 'U' : 'AI'}
</div>
<span className="text-xs font-semibold">
{message.role === 'user' ? 'You' : 'Assistant'}
</span>
</div>
{/* Message content with markdown */}
<div
className={`prose prose-sm ${
message.role === 'user' ? 'prose-invert' : ''
} max-w-none`}
>
<ReactMarkdown
components={{
// Custom code block renderer
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');
return !inline && match ? (
<div className="relative group">
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{codeString}
</SyntaxHighlighter>
<button
onClick={() => copyCode(codeString)}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
) : (
<code
className={`${
message.role === 'user'
? 'bg-blue-600'
: 'bg-gray-100'
} px-1 rounded`}
{...props}
>
{children}
</code>
);
},
}}
>
{message.content}
</ReactMarkdown>
</div>
{/* Timestamp */}
<div
className={`text-xs mt-2 ${
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
}`}
>
{new Date(message.createdAt || Date.now()).toLocaleTimeString()}
</div>
</div>
</div>
);
}
// Main chat component
export default function ChatWithCustomRenderer() {
const { messages, sendMessage, isLoading, error } = useChat({
api: '/api/chat',
});
const [input, setInput] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ content: input });
setInput('');
};
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto">
{/* Header */}
<div className="p-4 border-b bg-white">
<h1 className="text-2xl font-bold">Custom Message Renderer</h1>
<p className="text-sm text-gray-600">
With markdown, syntax highlighting, and copy buttons
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-4">
{messages.length === 0 && (
<div className="flex items-center justify-center h-full text-center">
<div>
<div className="text-6xl mb-4"></div>
<h2 className="text-xl font-semibold text-gray-700">
Try asking for code examples
</h2>
<p className="text-gray-500 mt-2">
Messages will render with markdown and syntax highlighting
</p>
<div className="mt-4 space-y-2">
{[
'Write a Python function to sort a list',
'Explain React hooks with code examples',
'Show me a TypeScript interface example',
].map((suggestion, idx) => (
<button
key={idx}
onClick={() => setInput(suggestion)}
className="block w-full p-2 text-left border rounded hover:bg-white"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
)}
{messages.map((message) => (
<MessageRenderer key={message.id} message={message} />
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white border p-3 rounded-lg">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200 text-red-700">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t bg-white">
<div className="flex space-x-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask for code examples..."
disabled={isLoading}
className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
// ============================================================================
// Simpler Version (without react-markdown)
// ============================================================================
/*
// Simple markdown parsing without external dependencies
function SimpleMarkdownRenderer({ content }: { content: string }) {
// Basic markdown parsing
const parseMarkdown = (text: string) => {
// Code blocks
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
return `<pre><code class="language-${lang || 'text'}">${code}</code></pre>`;
});
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks
text = text.replace(/\n/g, '<br/>');
return text;
};
return (
<div dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }} />
);
}
*/

View File

@@ -0,0 +1,293 @@
/**
* AI SDK UI - Message Persistence
*
* Demonstrates:
* - Saving chat history to localStorage
* - Loading previous conversations
* - Multiple chat sessions
* - Clear history functionality
*
* Features:
* - Auto-save on message changes
* - Persistent chat IDs
* - Load on mount
* - Clear/delete chats
*
* Usage:
* 1. Copy this component
* 2. Customize storage mechanism (localStorage, database, etc.)
* 3. Add chat history UI if needed
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent, useEffect } from 'react';
import type { Message } from 'ai';
// Storage key prefix
const STORAGE_KEY_PREFIX = 'ai-chat-';
// Helper functions for localStorage
const saveMessages = (chatId: string, messages: Message[]) => {
try {
localStorage.setItem(
`${STORAGE_KEY_PREFIX}${chatId}`,
JSON.stringify(messages)
);
} catch (error) {
console.error('Failed to save messages:', error);
}
};
const loadMessages = (chatId: string): Message[] => {
try {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${chatId}`);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load messages:', error);
return [];
}
};
const clearMessages = (chatId: string) => {
try {
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${chatId}`);
} catch (error) {
console.error('Failed to clear messages:', error);
}
};
const listChats = (): string[] => {
const chats: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(STORAGE_KEY_PREFIX)) {
chats.push(key.replace(STORAGE_KEY_PREFIX, ''));
}
}
return chats;
};
export default function PersistentChat() {
// Generate or use existing chat ID
const [chatId, setChatId] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
// Initialize chat ID
useEffect(() => {
// Try to load from URL params or generate new
const params = new URLSearchParams(window.location.search);
const urlChatId = params.get('chatId');
if (urlChatId) {
setChatId(urlChatId);
} else {
// Generate new chat ID
const newChatId = `chat-${Date.now()}`;
setChatId(newChatId);
// Update URL
const url = new URL(window.location.href);
url.searchParams.set('chatId', newChatId);
window.history.replaceState({}, '', url.toString());
}
setIsLoaded(true);
}, []);
const { messages, setMessages, sendMessage, isLoading, error } = useChat({
api: '/api/chat',
id: chatId,
initialMessages: isLoaded ? loadMessages(chatId) : [],
});
const [input, setInput] = useState('');
// Save messages whenever they change
useEffect(() => {
if (chatId && messages.length > 0) {
saveMessages(chatId, messages);
}
}, [messages, chatId]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ content: input });
setInput('');
};
const handleClearChat = () => {
if (confirm('Are you sure you want to clear this chat?')) {
clearMessages(chatId);
setMessages([]);
}
};
const handleNewChat = () => {
const newChatId = `chat-${Date.now()}`;
setChatId(newChatId);
setMessages([]);
// Update URL
const url = new URL(window.location.href);
url.searchParams.set('chatId', newChatId);
window.history.pushState({}, '', url.toString());
};
if (!isLoaded) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-white">
<div>
<h1 className="text-2xl font-bold">Persistent Chat</h1>
<p className="text-sm text-gray-600">
Chat ID: <code className="bg-gray-100 px-1 rounded">{chatId}</code>
</p>
</div>
<div className="flex space-x-2">
<button
onClick={handleNewChat}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50"
>
New Chat
</button>
{messages.length > 0 && (
<button
onClick={handleClearChat}
className="px-3 py-1 text-sm border border-red-300 text-red-600 rounded hover:bg-red-50"
>
Clear
</button>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-center">
<div>
<div className="text-6xl mb-4">💾</div>
<h2 className="text-xl font-semibold text-gray-700">
Your conversation is saved
</h2>
<p className="text-gray-500 mt-2">
All messages are automatically saved to localStorage
</p>
</div>
</div>
) : (
<div className="space-y-4 max-w-3xl mx-auto">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white border shadow-sm'
}`}
>
{message.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white border p-3 rounded-lg shadow-sm">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200 text-red-700">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t bg-white">
<div className="max-w-4xl mx-auto">
<div className="flex space-x-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600"
>
Send
</button>
</div>
<div className="mt-2 text-xs text-gray-500 text-center">
{messages.length > 0 && (
<>Last saved: {new Date().toLocaleTimeString()}</>
)}
</div>
</div>
</form>
</div>
);
}
// ============================================================================
// Database Persistence Example (Supabase)
// ============================================================================
/*
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const saveMessagesToDB = async (chatId: string, messages: Message[]) => {
const { error } = await supabase
.from('chat_messages')
.upsert({ chat_id: chatId, messages, updated_at: new Date() });
if (error) console.error('Save error:', error);
};
const loadMessagesFromDB = async (chatId: string): Promise<Message[]> => {
const { data, error } = await supabase
.from('chat_messages')
.select('messages')
.eq('chat_id', chatId)
.single();
if (error) {
console.error('Load error:', error);
return [];
}
return data?.messages || [];
};
*/

View File

@@ -0,0 +1,248 @@
/**
* Next.js API Routes for useChat
*
* Shows both App Router and Pages Router patterns.
*
* Key Difference:
* - App Router: Use toDataStreamResponse()
* - Pages Router: Use pipeDataStreamToResponse()
*
* This file includes both patterns for reference.
*/
// ============================================================================
// APP ROUTER (Next.js 13+)
// ============================================================================
// Location: app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4-turbo'),
messages,
system: 'You are a helpful AI assistant.',
maxOutputTokens: 1000,
temperature: 0.7,
});
// App Router: Use toDataStreamResponse()
return result.toDataStreamResponse();
}
// ============================================================================
// PAGES ROUTER (Next.js 12 and earlier)
// ============================================================================
// Location: pages/api/chat.ts
/*
import type { NextApiRequest, NextApiResponse } from 'next';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { messages } = req.body;
const result = streamText({
model: openai('gpt-4-turbo'),
messages,
system: 'You are a helpful AI assistant.',
});
// Pages Router: Use pipeDataStreamToResponse()
return result.pipeDataStreamToResponse(res);
}
*/
// ============================================================================
// WITH ANTHROPIC (Claude)
// ============================================================================
/*
import { anthropic } from '@ai-sdk/anthropic';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-3-5-sonnet-20241022'),
messages,
});
return result.toDataStreamResponse();
}
*/
// ============================================================================
// WITH GOOGLE (Gemini)
// ============================================================================
/*
import { google } from '@ai-sdk/google';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: google('gemini-1.5-pro'),
messages,
});
return result.toDataStreamResponse();
}
*/
// ============================================================================
// WITH CLOUDFLARE WORKERS AI
// ============================================================================
/*
// Requires: workers-ai-provider
import { createWorkersAI } from 'workers-ai-provider';
// For Cloudflare Workers (not Next.js):
export default {
async fetch(request, env) {
const { messages } = await request.json();
const workersai = createWorkersAI({ binding: env.AI });
const result = streamText({
model: workersai('@cf/meta/llama-3.1-8b-instruct'),
messages,
});
return result.toDataStreamResponse();
},
};
*/
// ============================================================================
// WITH ERROR HANDLING
// ============================================================================
/*
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4-turbo'),
messages,
});
return result.toDataStreamResponse();
} catch (error) {
console.error('API error:', error);
return new Response(
JSON.stringify({
error: 'An error occurred while processing your request.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
*/
// ============================================================================
// WITH TOOLS
// ============================================================================
/*
import { streamText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4-turbo'),
messages,
tools: {
getWeather: tool({
description: 'Get the current weather for a location',
inputSchema: z.object({
location: z.string().describe('The city name'),
}),
execute: async ({ location }) => {
// Simulated weather API call
return {
location,
temperature: 72,
condition: 'sunny',
};
},
}),
},
});
return result.toDataStreamResponse();
}
*/
// ============================================================================
// FOR useCompletion
// ============================================================================
// Location: app/api/completion/route.ts
/*
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: openai('gpt-3.5-turbo'),
prompt,
maxOutputTokens: 500,
});
return result.toDataStreamResponse();
}
*/
// ============================================================================
// FOR useObject
// ============================================================================
// Location: app/api/recipe/route.ts
/*
import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamObject({
model: openai('gpt-4'),
schema: z.object({
recipe: z.object({
name: z.string(),
ingredients: z.array(z.string()),
instructions: z.array(z.string()),
}),
}),
prompt: `Generate a recipe for ${prompt}`,
});
return result.toTextStreamResponse();
}
*/

View File

@@ -0,0 +1,238 @@
/**
* Next.js App Router - Complete Chat Example
*
* Complete production-ready chat interface for Next.js App Router.
*
* Features:
* - v5 useChat with manual input management
* - Auto-scroll to bottom
* - Loading states & error handling
* - Stop generation button
* - Responsive design
* - Keyboard shortcuts (Enter to send, Cmd+K to clear)
*
* Directory structure:
* app/
* ├── chat/
* │ └── page.tsx (this file)
* └── api/
* └── chat/
* └── route.ts (see nextjs-api-route.ts)
*
* Usage:
* 1. Copy to app/chat/page.tsx
* 2. Create API route (see nextjs-api-route.ts)
* 3. Navigate to /chat
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent, useRef, useEffect } from 'react';
export default function ChatPage() {
const { messages, sendMessage, isLoading, error, stop, reload } = useChat({
api: '/api/chat',
onError: (error) => {
console.error('Chat error:', error);
},
});
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ content: input });
setInput('');
};
// Keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
// Cmd+K or Ctrl+K to clear (focus input)
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
};
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto" onKeyDown={handleKeyDown}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-white">
<div>
<h1 className="text-2xl font-bold">AI Assistant</h1>
<p className="text-sm text-gray-600">
{messages.length > 0
? `${messages.length} message${messages.length === 1 ? '' : 's'}`
: 'Start a conversation'}
</p>
</div>
{messages.length > 0 && !isLoading && (
<button
onClick={() => window.location.reload()}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50"
>
New Chat
</button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.length === 0 ? (
// Empty state
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<div className="text-4xl">💬</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">
Start a conversation
</h2>
<p className="text-gray-600 mt-2">
Ask me anything or try one of these:
</p>
</div>
<div className="grid gap-2 max-w-md">
{[
'Explain quantum computing',
'Write a haiku about coding',
'Plan a trip to Tokyo',
].map((suggestion, idx) => (
<button
key={idx}
onClick={() => setInput(suggestion)}
className="p-3 text-left border rounded-lg hover:bg-white hover:shadow-sm transition-all"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
) : (
// Messages list
<div className="space-y-4 max-w-3xl mx-auto">
{messages.map((message, idx) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[75%] rounded-lg p-4 ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white border shadow-sm'
}`}
>
{/* Role label (only for assistant on first message) */}
{message.role === 'assistant' && idx === 1 && (
<div className="text-xs font-semibold text-gray-500 mb-2">
AI Assistant
</div>
)}
{/* Message content */}
<div className="whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white border rounded-lg p-4 shadow-sm">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
<span className="text-sm text-gray-600">Thinking...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Error banner */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="flex items-center space-x-2 text-red-700">
<span className="text-xl"></span>
<div>
<div className="font-semibold">Error</div>
<div className="text-sm">{error.message}</div>
</div>
</div>
<button
onClick={reload}
className="px-3 py-1 text-sm border border-red-300 rounded hover:bg-red-100"
>
Retry
</button>
</div>
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t bg-white">
<div className="max-w-4xl mx-auto">
<div className="flex space-x-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 font-medium"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600 font-medium"
>
Send
</button>
)}
</div>
<div className="mt-2 text-xs text-gray-500 text-center">
Press Enter to send Cmd+K to focus input
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,162 @@
/**
* Next.js Pages Router - Complete Chat Example
*
* Complete production-ready chat interface for Next.js Pages Router.
*
* Features:
* - v5 useChat with manual input management
* - Auto-scroll to bottom
* - Loading states & error handling
* - Stop generation button
* - Responsive design
*
* Directory structure:
* pages/
* ├── chat.tsx (this file)
* └── api/
* └── chat.ts (see nextjs-api-route.ts)
*
* Usage:
* 1. Copy to pages/chat.tsx
* 2. Create API route at pages/api/chat.ts (see nextjs-api-route.ts)
* 3. Navigate to /chat
*/
import { useChat } from 'ai/react';
import { useState, FormEvent, useRef, useEffect } from 'react';
import Head from 'next/head';
export default function ChatPage() {
const { messages, sendMessage, isLoading, error, stop } = useChat({
api: '/api/chat',
});
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ content: input });
setInput('');
};
return (
<>
<Head>
<title>AI Chat</title>
<meta name="description" content="Chat with AI" />
</Head>
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Header */}
<div className="p-4 border-b bg-white">
<h1 className="text-2xl font-bold">AI Chat</h1>
<p className="text-sm text-gray-600">
Powered by AI SDK v5 (Pages Router)
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.length === 0 ? (
// Empty state
<div className="flex items-center justify-center h-full text-center">
<div>
<div className="text-6xl mb-4">💬</div>
<h2 className="text-xl font-semibold text-gray-700">
Start a conversation
</h2>
<p className="text-gray-500 mt-2">
Type a message below to begin
</p>
</div>
</div>
) : (
// Messages list
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white border shadow-sm'
}`}
>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white border p-3 rounded-lg shadow-sm">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200">
<div className="text-red-700">
<strong>Error:</strong> {error.message}
</div>
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t bg-white">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600"
>
Send
</button>
)}
</div>
</form>
</div>
</>
);
}

45
templates/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "ai-sdk-ui-app",
"version": "0.1.0",
"private": true,
"description": "AI SDK UI application with React hooks for chat, completion, and streaming",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"ai": "^5.0.95",
"@ai-sdk/openai": "^2.0.68",
"@ai-sdk/anthropic": "^2.0.45",
"@ai-sdk/google": "^2.0.38",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"next": "^14.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.3.3",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0"
},
"optionalDependencies": {
"react-markdown": "^9.0.0",
"react-syntax-highlighter": "^15.5.0",
"@types/react-syntax-highlighter": "^15.5.0",
"workers-ai-provider": "^2.0.0"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"packageManager": "npm@10.0.0",
"comment": "Engine versions specify minimum supported versions for compatibility"
}

View File

@@ -0,0 +1,230 @@
/**
* AI SDK UI - Chat with File Attachments
*
* Demonstrates:
* - File upload with experimental_attachments
* - Image preview
* - Multiple file support
* - Sending files with messages
*
* Requires:
* - API route that handles multimodal inputs (GPT-4 Vision, Claude 3.5, etc.)
* - experimental_attachments feature (v5)
*
* Usage:
* 1. Set up API route with vision model
* 2. Copy this component
* 3. Customize file handling as needed
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent } from 'react';
export default function ChatWithAttachments() {
const { messages, sendMessage, isLoading, error } = useChat({
api: '/api/chat',
});
const [input, setInput] = useState('');
const [files, setFiles] = useState<FileList | null>(null);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
// Handle file selection
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
setFiles(selectedFiles);
if (selectedFiles) {
// Create preview URLs
const urls = Array.from(selectedFiles).map((file) =>
URL.createObjectURL(file)
);
setPreviewUrls(urls);
} else {
setPreviewUrls([]);
}
};
// Handle form submission
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim() && !files) return;
sendMessage({
content: input || 'Please analyze these images',
experimental_attachments: files
? Array.from(files).map((file) => ({
name: file.name,
contentType: file.type,
url: URL.createObjectURL(file),
}))
: undefined,
});
// Clean up
setInput('');
setFiles(null);
previewUrls.forEach((url) => URL.revokeObjectURL(url));
setPreviewUrls([]);
};
// Remove file
const removeFile = (index: number) => {
if (!files) return;
const newFiles = Array.from(files).filter((_, i) => i !== index);
const dataTransfer = new DataTransfer();
newFiles.forEach((file) => dataTransfer.items.add(file));
setFiles(dataTransfer.files);
// Update preview URLs
URL.revokeObjectURL(previewUrls[index]);
setPreviewUrls(previewUrls.filter((_, i) => i !== index));
};
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Header */}
<div className="p-4 border-b">
<h1 className="text-2xl font-bold">AI Chat with File Attachments</h1>
<p className="text-sm text-gray-600">
Upload images and ask questions about them
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div key={message.id} className="space-y-2">
{/* Text content */}
<div
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-900'
}`}
>
{message.content}
</div>
</div>
{/* Attachments */}
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<div
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div className="grid grid-cols-2 gap-2 max-w-[70%]">
{message.experimental_attachments.map(
(attachment, idx) => (
<div key={idx} className="relative">
{attachment.contentType?.startsWith('image/') ? (
<img
src={attachment.url}
alt={attachment.name}
className="rounded-lg max-h-40 object-cover"
/>
) : (
<div className="p-2 bg-gray-100 rounded-lg text-sm">
{attachment.name}
</div>
)}
</div>
)
)}
</div>
</div>
)}
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-200 p-3 rounded-lg">Processing...</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200 text-red-700">
<strong>Error:</strong> {error.message}
</div>
)}
{/* File preview */}
{previewUrls.length > 0 && (
<div className="p-4 border-t bg-gray-50">
<p className="text-sm text-gray-700 mb-2">
Selected files ({previewUrls.length}):
</p>
<div className="grid grid-cols-4 gap-2">
{previewUrls.map((url, idx) => (
<div key={idx} className="relative">
<img
src={url}
alt={`Preview ${idx + 1}`}
className="rounded-lg h-20 w-full object-cover"
/>
<button
type="button"
onClick={() => removeFile(idx)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="space-y-2">
{/* File input */}
<label className="flex items-center space-x-2 cursor-pointer">
<div className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
📎 Attach Files
</div>
<input
type="file"
multiple
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{files && <span className="text-sm text-gray-600">{files.length} file(s)</span>}
</label>
{/* Text input */}
<div className="flex space-x-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question about the images..."
disabled={isLoading}
className="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || (!input.trim() && !files)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,133 @@
/**
* AI SDK UI - Basic Chat Component (v5)
*
* Demonstrates:
* - useChat hook with v5 manual input management
* - Streaming chat messages
* - Loading states
* - Error handling
* - Auto-scroll to latest message
*
* CRITICAL v5 Change: useChat NO LONGER manages input state!
* You must manually manage input with useState.
*
* Usage:
* 1. Copy this component to your app
* 2. Create API route (see nextjs-api-route.ts)
* 3. Customize styling as needed
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent, useRef, useEffect } from 'react';
export default function ChatBasic() {
// useChat hook - v5 style
const { messages, sendMessage, isLoading, error, stop } = useChat({
api: '/api/chat',
});
// Manual input management (v5 requires this!)
const [input, setInput] = useState('');
// Auto-scroll to bottom
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Handle form submission
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
// v5: Use sendMessage instead of append
sendMessage({ content: input });
setInput('');
};
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto">
{/* Header */}
<div className="p-4 border-b">
<h1 className="text-2xl font-bold">AI Chat</h1>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-900'
}`}
>
{message.content}
</div>
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-200 p-3 rounded-lg">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Error message */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200 text-red-700">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex space-x-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
className="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600"
>
Send
</button>
)}
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,166 @@
/**
* AI SDK UI - Chat with Tool Calling
*
* Demonstrates:
* - Displaying tool calls in UI
* - Rendering tool arguments and results
* - Handling multi-step tool invocations
* - Visual distinction between messages and tool calls
*
* Requires:
* - API route with tools configured (see ai-sdk-core skill)
* - Backend using `tool()` helper
*
* Usage:
* 1. Set up API route with tools
* 2. Copy this component
* 3. Customize tool rendering as needed
*/
'use client';
import { useChat } from 'ai/react';
import { useState, FormEvent } from 'react';
export default function ChatWithTools() {
const { messages, sendMessage, isLoading, error } = useChat({
api: '/api/chat',
});
const [input, setInput] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ content: input });
setInput('');
};
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Header */}
<div className="p-4 border-b">
<h1 className="text-2xl font-bold">AI Chat with Tools</h1>
<p className="text-sm text-gray-600">
Ask about weather, calculations, or search queries
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div key={message.id} className="space-y-2">
{/* Text content */}
{message.content && (
<div
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-900'
}`}
>
{message.content}
</div>
</div>
)}
{/* Tool invocations */}
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex justify-start">
<div className="max-w-[85%] space-y-2">
{message.toolInvocations.map((tool, idx) => (
<div
key={idx}
className="border border-blue-200 bg-blue-50 p-3 rounded-lg"
>
{/* Tool name */}
<div className="flex items-center space-x-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<span className="font-semibold text-blue-900">
Tool: {tool.toolName}
</span>
</div>
{/* Tool state */}
{tool.state === 'call' && (
<div className="text-sm text-blue-700">
<strong>Calling with:</strong>
<pre className="mt-1 p-2 bg-white rounded text-xs overflow-x-auto">
{JSON.stringify(tool.args, null, 2)}
</pre>
</div>
)}
{tool.state === 'result' && (
<div className="text-sm text-blue-700">
<strong>Arguments:</strong>
<pre className="mt-1 p-2 bg-white rounded text-xs overflow-x-auto">
{JSON.stringify(tool.args, null, 2)}
</pre>
<strong className="block mt-2">Result:</strong>
<pre className="mt-1 p-2 bg-white rounded text-xs overflow-x-auto">
{JSON.stringify(tool.result, null, 2)}
</pre>
</div>
)}
{tool.state === 'partial-call' && (
<div className="text-sm text-blue-600 italic">
Preparing arguments...
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-200 p-3 rounded-lg">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border-t border-red-200 text-red-700">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex space-x-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Try: 'What's the weather in San Francisco?'"
disabled={isLoading}
className="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,170 @@
/**
* AI SDK UI - Basic Text Completion
*
* Demonstrates:
* - useCompletion hook for text generation
* - Streaming text completion
* - Loading states
* - Stop generation
* - Clear completion
*
* Use cases:
* - Text generation (blog posts, summaries, etc.)
* - Content expansion
* - Writing assistance
*
* Usage:
* 1. Copy this component to your app
* 2. Create /api/completion route (see references)
* 3. Customize UI as needed
*/
'use client';
import { useCompletion } from 'ai/react';
import { useState, FormEvent } from 'react';
export default function CompletionBasic() {
const {
completion,
complete,
isLoading,
error,
stop,
setCompletion,
} = useCompletion({
api: '/api/completion',
});
const [input, setInput] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
complete(input);
setInput('');
};
const handleClear = () => {
setCompletion('');
};
return (
<div className="max-w-3xl mx-auto p-4 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">AI Text Completion</h1>
<p className="text-gray-600 mt-2">
Enter a prompt to generate text with AI
</p>
</div>
{/* Input form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="prompt"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prompt
</label>
<textarea
id="prompt"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Write a blog post about..."
rows={4}
disabled={isLoading}
className="w-full p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
<div className="flex space-x-2">
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Stop Generation
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600"
>
Generate
</button>
)}
{completion && (
<button
type="button"
onClick={handleClear}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
>
Clear
</button>
)}
</div>
</form>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Completion output */}
{(completion || isLoading) && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Generated Text</h2>
{isLoading && (
<div className="flex items-center space-x-2 text-sm text-gray-600">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce delay-200" />
<span>Generating...</span>
</div>
)}
</div>
<div className="p-4 bg-gray-50 border rounded-lg whitespace-pre-wrap">
{completion || 'Waiting for response...'}
</div>
{!isLoading && completion && (
<div className="text-sm text-gray-600">
{completion.split(/\s+/).length} words, {completion.length}{' '}
characters
</div>
)}
</div>
)}
{/* Example prompts */}
{!completion && !isLoading && (
<div className="space-y-2">
<h3 className="font-semibold">Example prompts:</h3>
<div className="space-y-2">
{[
'Write a blog post about the future of AI',
'Explain quantum computing in simple terms',
'Create a recipe for chocolate chip cookies',
'Write a product description for wireless headphones',
].map((example, idx) => (
<button
key={idx}
onClick={() => setInput(example)}
className="block w-full text-left p-2 border rounded hover:bg-gray-50"
>
{example}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,259 @@
/**
* AI SDK UI - Streaming Structured Data
*
* Demonstrates:
* - useObject hook for streaming structured data
* - Partial object updates (live as schema fields fill in)
* - Zod schema validation
* - Loading states
* - Error handling
*
* Use cases:
* - Forms generation
* - Recipe creation
* - Product specs
* - Structured content generation
*
* Usage:
* 1. Copy this component
* 2. Create /api/object route with streamObject
* 3. Define Zod schema matching your needs
*/
'use client';
import { useObject } from 'ai/react';
import { z } from 'zod';
import { FormEvent, useState } from 'react';
// Define the schema for the object
const recipeSchema = z.object({
recipe: z.object({
name: z.string().describe('Recipe name'),
description: z.string().describe('Short description'),
prepTime: z.number().describe('Preparation time in minutes'),
cookTime: z.number().describe('Cooking time in minutes'),
servings: z.number().describe('Number of servings'),
difficulty: z.enum(['easy', 'medium', 'hard']),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
})
),
instructions: z.array(z.string()),
}),
});
export default function ObjectStreaming() {
const { object, submit, isLoading, error, stop } = useObject({
api: '/api/recipe',
schema: recipeSchema,
});
const [input, setInput] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
submit(input);
setInput('');
};
return (
<div className="max-w-4xl mx-auto p-4 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">AI Recipe Generator</h1>
<p className="text-gray-600 mt-2">
Streaming structured data with live updates
</p>
</div>
{/* Input form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="dish"
className="block text-sm font-medium text-gray-700 mb-2"
>
What would you like to cook?
</label>
<input
id="dish"
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="e.g., 'chocolate chip cookies' or 'thai green curry'"
disabled={isLoading}
className="w-full p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
<div className="flex space-x-2">
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-600"
>
Generate Recipe
</button>
)}
</div>
</form>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
<strong>Error:</strong> {error.message}
</div>
)}
{/* Generated recipe */}
{(object?.recipe || isLoading) && (
<div className="border rounded-lg p-6 space-y-6 bg-white shadow-sm">
{/* Recipe header */}
<div className="border-b pb-4">
<h2 className="text-2xl font-bold">
{object?.recipe?.name || (
<span className="text-gray-400 italic">
{isLoading ? 'Generating name...' : 'Recipe name'}
</span>
)}
</h2>
{object?.recipe?.description && (
<p className="text-gray-600 mt-2">{object.recipe.description}</p>
)}
</div>
{/* Recipe meta */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<div className="font-semibold text-gray-700">Prep Time</div>
<div>
{object?.recipe?.prepTime ? (
`${object.recipe.prepTime} min`
) : (
<span className="text-gray-400">...</span>
)}
</div>
</div>
<div>
<div className="font-semibold text-gray-700">Cook Time</div>
<div>
{object?.recipe?.cookTime ? (
`${object.recipe.cookTime} min`
) : (
<span className="text-gray-400">...</span>
)}
</div>
</div>
<div>
<div className="font-semibold text-gray-700">Servings</div>
<div>
{object?.recipe?.servings || (
<span className="text-gray-400">...</span>
)}
</div>
</div>
<div>
<div className="font-semibold text-gray-700">Difficulty</div>
<div className="capitalize">
{object?.recipe?.difficulty || (
<span className="text-gray-400">...</span>
)}
</div>
</div>
</div>
{/* Ingredients */}
<div>
<h3 className="text-xl font-semibold mb-3">Ingredients</h3>
{object?.recipe?.ingredients &&
object.recipe.ingredients.length > 0 ? (
<ul className="space-y-2">
{object.recipe.ingredients.map((ingredient, idx) => (
<li key={idx} className="flex items-start">
<span className="text-blue-500 mr-2"></span>
<span>
{ingredient.amount} {ingredient.item}
</span>
</li>
))}
</ul>
) : (
<p className="text-gray-400 italic">
{isLoading ? 'Loading ingredients...' : 'No ingredients yet'}
</p>
)}
</div>
{/* Instructions */}
<div>
<h3 className="text-xl font-semibold mb-3">Instructions</h3>
{object?.recipe?.instructions &&
object.recipe.instructions.length > 0 ? (
<ol className="space-y-3">
{object.recipe.instructions.map((step, idx) => (
<li key={idx} className="flex items-start">
<span className="flex-shrink-0 w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm mr-3 mt-0.5">
{idx + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
) : (
<p className="text-gray-400 italic">
{isLoading ? 'Loading instructions...' : 'No instructions yet'}
</p>
)}
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center justify-center space-x-2 text-blue-600 py-4">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce delay-200" />
<span>Generating recipe...</span>
</div>
)}
</div>
)}
{/* Example prompts */}
{!object && !isLoading && (
<div className="space-y-2">
<h3 className="font-semibold">Try these:</h3>
<div className="grid grid-cols-2 gap-2">
{[
'Chocolate chip cookies',
'Thai green curry',
'Classic margarita pizza',
'Banana bread',
].map((example, idx) => (
<button
key={idx}
onClick={() => setInput(example)}
className="text-left p-3 border rounded-lg hover:bg-gray-50"
>
{example}
</button>
))}
</div>
</div>
)}
</div>
);
}