AI Chat
intermediateProduction-ready AI chat with streaming responses, conversation history, and provider abstraction supporting OpenAI and Anthropic.
$ bunx sinew add ai/ai-chat1The Problem
Building AI chat interfaces requires handling:
- Streaming responses for real-time UX
- Conversation history management
- Provider abstraction for flexibility
- Session persistence across page reloads
2The Solution
Use the Vercel AI SDK with provider abstraction supporting OpenAI and Anthropic. Persist conversation history in Upstash Redis for serverless compatibility.
3Files
lib/ai/providers.ts
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";
export type AIProvider = "openai" | "anthropic";
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const anthropic = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export const models = {
openai: {
default: openai("gpt-4o"),
fast: openai("gpt-4o-mini"),
reasoning: openai("o1"),
},
anthropic: {
default: anthropic("claude-sonnet-4-20250514"),
fast: anthropic("claude-3-5-haiku-20241022"),
reasoning: anthropic("claude-sonnet-4-20250514"),
},
} as const;
export type ModelType = "default" | "fast" | "reasoning";
export function getProvider(): AIProvider {
const provider = process.env.AI_PROVIDER as AIProvider;
if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
return "anthropic";
}
return "openai";
}
export function getModel(type: ModelType = "default") {
const provider = getProvider();
return models[provider][type];
}lib/ai/chat.ts
import { streamText, type ModelMessage } from "ai";
import { Redis } from "@upstash/redis";
import { getModel, type ModelType } from "./providers";
const redis = Redis.fromEnv();
export async function streamChat({
sessionId,
message,
systemPrompt,
modelType = "default",
}: {
sessionId: string;
message: string;
systemPrompt?: string;
modelType?: ModelType;
}) {
const messages = await getMessages(sessionId);
const userMessage: ModelMessage = { role: "user", content: message };
messages.push(userMessage);
const model = getModel(modelType);
const result = streamText({
model,
system: systemPrompt ?? "You are a helpful assistant.",
messages,
onFinish: async ({ text }) => {
try {
messages.push({ role: "assistant", content: text });
await saveMessages(sessionId, messages);
} catch (error) {
console.error("Failed to persist chat history:", error);
}
},
});
return result;
}
async function getMessages(sessionId: string): Promise<ModelMessage[]> {
// @upstash/redis deserializes JSON automatically — read the array directly.
const data = await redis.get<ModelMessage[]>(`chat:${sessionId}`);
return data ?? [];
}
async function saveMessages(sessionId: string, messages: ModelMessage[]) {
// Store the raw array (no JSON.stringify) and set a 24h TTL in one call.
await redis.set(`chat:${sessionId}`, messages, { ex: 60 * 60 * 24 });
}app/api/chat/route.ts
import { NextRequest } from "next/server";
import { streamChat } from "@/lib/ai/chat";
export const runtime = "edge";
export async function POST(req: NextRequest) {
const { message, sessionId, systemPrompt } = await req.json();
if (!message || !sessionId) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const result = await streamChat({ sessionId, message, systemPrompt });
return result.toUIMessageStreamResponse();
}components/chat-ui.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useState, type FormEvent } from "react";
function messageText(message: UIMessage): string {
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
}
export function ChatUI({ sessionId }: { sessionId: string }) {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
// The server rebuilds history from Redis using sessionId, so only the
// latest user message is sent.
transport: new DefaultChatTransport({
api: "/api/chat",
prepareSendMessagesRequest: ({ messages }) => {
const last = messages[messages.length - 1];
return { body: { sessionId, message: last ? messageText(last) : "" } };
},
}),
});
const isLoading = status === "submitted" || status === "streaming";
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isLoading) return;
sendMessage({ text });
setInput("");
};
return (
<div className="flex h-full flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((message) => (
<div key={message.id} className={message.role === "user" ? "text-right" : "text-left"}>
<p className="inline-block rounded-lg bg-gray-100 px-4 py-2">{messageText(message)}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="border-t p-4">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="w-full rounded-lg border px-4 py-2"
placeholder="Type a message..."
/>
</form>
</div>
);
}4Dependencies
$ bun add ai @ai-sdk/openai @ai-sdk/anthropic @upstash/redis5Configuration
Environment Variables
| Variable | Description | Required |
| -------------------------- | ----------------------- | ------------------------ |
| AI_PROVIDER | "openai" or "anthropic" | No (defaults to openai) |
| OPENAI_API_KEY | OpenAI API key | Yes (if using OpenAI) |
| ANTHROPIC_API_KEY | Anthropic API key | Yes (if using Anthropic) |
| UPSTASH_REDIS_REST_URL | Upstash Redis URL | Yes |
| UPSTASH_REDIS_REST_TOKEN | Upstash Redis token | Yes |
6Usage
import { ChatUI } from "@/components/chat-ui";
export default function ChatPage() {
return (
<div className="h-screen">
<ChatUI sessionId="user-123-session-1" />
</div>
);
}7Alternatives
- LangChain - Extensive ecosystem with built-in memory patterns
- LlamaIndex - Excellent for RAG applications
- Amazon Bedrock - Multiple model providers with AWS integration