Initial commit
This commit is contained in:
269
templates/custom-message-renderer.tsx
Normal file
269
templates/custom-message-renderer.tsx
Normal 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) }} />
|
||||
);
|
||||
}
|
||||
*/
|
||||
293
templates/message-persistence.tsx
Normal file
293
templates/message-persistence.tsx
Normal 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 || [];
|
||||
};
|
||||
*/
|
||||
248
templates/nextjs-api-route.ts
Normal file
248
templates/nextjs-api-route.ts
Normal 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();
|
||||
}
|
||||
*/
|
||||
238
templates/nextjs-chat-app-router.tsx
Normal file
238
templates/nextjs-chat-app-router.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
templates/nextjs-chat-pages-router.tsx
Normal file
162
templates/nextjs-chat-pages-router.tsx
Normal 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
45
templates/package.json
Normal 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"
|
||||
}
|
||||
230
templates/use-chat-attachments.tsx
Normal file
230
templates/use-chat-attachments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
templates/use-chat-basic.tsx
Normal file
133
templates/use-chat-basic.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
templates/use-chat-tools.tsx
Normal file
166
templates/use-chat-tools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
templates/use-completion-basic.tsx
Normal file
170
templates/use-completion-basic.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
templates/use-object-streaming.tsx
Normal file
259
templates/use-object-streaming.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user