Initial ChatGPT Agent demo

This commit is contained in:
Rami Bitar
2026-09-14 19:49:51 -04:00
commit 8790cfb1e2
121 changed files with 20112 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import type { ChatStatus } from 'ai';
import { ChatInput, type PromptInputMessage } from './ChatInput';
import { ChatMessages } from './ChatMessages';
import { ChatPrompt, PromptSuggestion } from './ChatPrompt';
import { ChatSidebar, type Chat } from './ChatSidebar';
import { useChat, UIMessage } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
createChat,
deleteChat,
getMessages,
listChats,
saveMessages,
} from '@/services/indexed-db/chat-store';
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from '@/components/ui/sidebar';
interface ChatAgentProps {
suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean;
}
async function convertToDataUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the selected file');
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not encode the selected file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
export function ChatAgent({
suggestions,
webSearchEnabled = false,
}: ChatAgentProps) {
const [inputValue, setInputValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const chatIdRef = useRef(chatId);
const pendingChatIdRef = useRef<string | null>(null);
chatIdRef.current = chatId;
const fetchChats = useCallback(async () => {
try {
setChats(await listChats());
} catch (error) {
console.error('Failed to fetch chats:', error);
}
}, []);
const transportRef = useRef(
new DefaultChatTransport({
api: '/api/chat',
})
);
const {
status,
messages: chatMessages,
setMessages: setChatMessages,
sendMessage,
stop,
error: aiError,
addToolApprovalResponse,
} = useChat({
transport: transportRef.current,
onFinish: async ({ messages }) => {
const activeChatId = pendingChatIdRef.current ?? chatIdRef.current;
if (!activeChatId) return;
try {
await saveMessages(activeChatId, messages);
await fetchChats();
} catch (error) {
console.error('Failed to save messages:', error);
} finally {
if (pendingChatIdRef.current === activeChatId) {
pendingChatIdRef.current = null;
}
}
},
onError: (error) => {
console.error('Chat error:', error);
},
});
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const chatStatus: ChatStatus = isPreparingFiles
? 'submitted'
: status === 'submitted' || status === 'streaming'
? status
: 'ready';
useEffect(() => {
fetchChats();
}, [fetchChats]);
const handleSelectChat = useCallback(
async (selectedChatId: string) => {
try {
const messages = await getMessages(selectedChatId);
chatIdRef.current = selectedChatId;
setChatId(selectedChatId);
setChatMessages(messages);
} catch (error) {
console.error('Failed to load chat:', error);
}
},
[setChatMessages]
);
const handleNewChat = useCallback(() => {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
setInputValue('');
}, [setChatMessages]);
const handleDeleteChat = useCallback(
async (deletedChatId: string) => {
try {
await deleteChat(deletedChatId);
setChats((prev) => prev.filter((c) => c.id !== deletedChatId));
if (chatId === deletedChatId) {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
}
} catch (error) {
console.error('Failed to delete chat:', error);
}
},
[chatId, setChatMessages]
);
const handleSendMessage = useCallback(
async (message: PromptInputMessage) => {
const trimmedValue = message.text?.trim();
if (!trimmedValue && !message.files?.length) return;
if (
status === 'submitted' ||
status === 'streaming' ||
isPreparingFiles
)
return;
let activeChatId = chatIdRef.current;
if (!chatIdRef.current) {
const newChatId = crypto.randomUUID();
const title = trimmedValue ? trimmedValue.slice(0, 100) : 'New Chat';
activeChatId = newChatId;
chatIdRef.current = newChatId;
setChatId(newChatId);
try {
await createChat(newChatId, title);
await fetchChats();
} catch (error) {
console.error('Failed to create chat:', error);
}
}
const parts: Array<
| { type: 'text'; text: string }
| { type: 'file'; url: string; mediaType: string; filename: string }
> = [];
if (trimmedValue) {
parts.push({ type: 'text', text: trimmedValue });
}
// PromptInput supplies files as base64 data URLs. Keep them inline so the
// demo never needs a remote storage bucket.
if (message.files?.length) {
setIsPreparingFiles(true);
for (const file of message.files) {
try {
const dataUrl = file.url.startsWith('data:')
? file.url
: await convertToDataUrl(file.url);
parts.push({
type: 'file',
url: dataUrl,
mediaType: file.mediaType || 'application/octet-stream',
filename: file.filename || 'file',
});
} catch (err) {
console.error('File encoding error:', err);
}
}
setIsPreparingFiles(false);
}
if (parts.length === 0) return;
const userMessage: UIMessage = {
id: crypto.randomUUID(),
role: 'user',
parts,
};
setInputValue('');
if (activeChatId) {
try {
await saveMessages(activeChatId, [...chatMessages, userMessage]);
} catch (error) {
console.error('Failed to save user message:', error);
}
}
pendingChatIdRef.current = activeChatId;
await sendMessage(userMessage);
},
[
status,
sendMessage,
chatMessages,
isPreparingFiles,
fetchChats,
]
);
const handleStop = useCallback(() => {
stop();
}, [stop]);
const hasMessages = chatMessages.length > 0;
return (
<SidebarProvider defaultOpen={false}>
<ChatSidebar
chats={chats}
activeChatId={chatId}
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
/>
<SidebarInset className="bg-muted">
{!hasMessages ? (
<div className="relative">
<div className="absolute top-2 left-2 z-10">
<SidebarTrigger />
</div>
<ChatPrompt
value={inputValue}
onChange={setInputValue}
onSubmit={handleSendMessage}
status={chatStatus}
suggestions={suggestions}
webSearchEnabled={webSearchEnabled}
/>
</div>
) : (
<div className="relative flex h-screen flex-col bg-muted">
<div className="absolute top-2 left-2 z-10">
<SidebarTrigger />
</div>
<div className="flex-1 overflow-hidden">
<ChatMessages
messages={chatMessages}
isLoading={status === 'submitted' || status === 'streaming'}
/>
</div>
<div className="sticky bottom-0 bg-muted p-4">
<div className="mx-auto max-w-3xl">
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSendMessage}
status={chatStatus}
/>
</div>
</div>
</div>
)}
</SidebarInset>
</SidebarProvider>
);
}
export type { UIMessage as Message };
+77
View File
@@ -0,0 +1,77 @@
'use client';
import type { ChatStatus } from 'ai';
import { PlusIcon } from 'lucide-react';
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputFooter,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '@/components/ai-elements/prompt-input';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void;
status?: ChatStatus;
placeholder?: string;
}
export function ChatInput({
value,
onChange,
onSubmit,
status = 'ready',
placeholder,
}: ChatInputProps) {
return (
<PromptInput
className="[&_[data-slot=input-group]]:bg-background"
globalDrop
multiple
onSubmit={onSubmit}
>
<PromptInputHeader className="pt-1.5 pb-0">
<PromptInputAttachments className="gap-1.5 px-3 py-0">
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
className="min-h-10 py-2"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
</PromptInputBody>
<PromptInputFooter className="pt-0 pb-2">
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger>
<PlusIcon className="size-4" />
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit
disabled={status === 'streaming'}
status={status}
/>
</PromptInputFooter>
</PromptInput>
);
}
export type { PromptInputMessage };
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { cn } from "@/lib/utils";
import { Tool, UIMessage, UIMessagePart } from "ai";
import { MessagePartRenderer } from "./ChatMessageParts";
interface ChatMessageProps {
message: UIMessage;
}
function getTextContent(parts: UIMessagePart<any, any>[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"flex w-full",
isUser ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-3",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
<div
className={cn(
"prose prose-sm max-w-none",
isUser
? "prose-invert prose-p:text-primary-foreground prose-headings:text-primary-foreground prose-strong:text-primary-foreground prose-code:text-primary-foreground"
: "prose-p:text-foreground prose-headings:text-foreground prose-strong:text-foreground"
)}
>
{message.parts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
))}
</div>
</div>
</div>
);
}
export { getTextContent };
export type { UIMessage as Message };
+96
View File
@@ -0,0 +1,96 @@
"use client";
import type { UIMessage } from "ai";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
import { Loader } from "@/components/ai-elements/loader";
import { toolComponents } from "@/chat.config";
type MessagePart = UIMessage["parts"][number];
type ReasoningPart = Extract<MessagePart, { type: "reasoning" }>;
// Helper functions
export function getTextContent(parts: MessagePart[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function getReasoningPart(parts: MessagePart[]): ReasoningPart | undefined {
return parts.find((p): p is ReasoningPart => p.type === "reasoning");
}
function extractToolName(part: MessagePart): string {
if (part.type === "dynamic-tool") {
return (part as any).toolName || "unknown";
}
if (part.type.startsWith("tool-")) {
return part.type.slice(5);
}
return "unknown";
}
function DefaultToolRenderer({ part, toolName }: { part: MessagePart; toolName: string }) {
const { input, output, state, errorText } = part as any;
return (
<Tool>
<ToolHeader
title={toolName}
type={part.type as any}
state={state || "input-streaming"}
/>
<ToolContent>
{input && <ToolInput input={input} />}
<ToolOutput output={output} errorText={errorText} />
</ToolContent>
</Tool>
);
}
function ToolLoadingIndicator({ toolName }: { toolName: string }) {
return (
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
<Loader size={14} />
<span>Running {toolName}...</span>
</div>
);
}
function RenderTool({ part }: { part: MessagePart }) {
const toolName = extractToolName(part);
const state = (part as any).state;
const isComplete = state === "output-available";
const CustomRenderer = (
toolComponents as Record<
string,
React.ComponentType<{ part: MessagePart; toolName: string }>
>
)[toolName];
if (CustomRenderer) {
return <CustomRenderer part={part} toolName={toolName} />;
}
if (!isComplete && !(part as any).input) {
return <ToolLoadingIndicator toolName={toolName} />;
}
return <DefaultToolRenderer part={part} toolName={toolName} />;
}
export function ToolPartRenderer({ part }: { part: MessagePart }) {
return <RenderTool part={part} />;
}
export function MessagePartRenderer({ part }: { part: MessagePart }) {
if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
return <RenderTool part={part} />;
}
return null;
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import type { UIMessage } from "ai";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageAttachment,
MessageAttachments,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Loader } from "@/components/ai-elements/loader";
import { ToolPartRenderer, getTextContent, getReasoningPart } from "./ChatMessageParts";
interface ChatMessagesProps {
messages: UIMessage[];
isLoading?: boolean;
}
function LoadingIndicator() {
return (
<Message from="assistant">
<MessageContent from="assistant">
<Loader />
</MessageContent>
</Message>
);
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const isToolPart = (type: string) => type === "file" || type.startsWith("tool-");
const hasReasoningPart = (parts: typeof messages[0]["parts"]) =>
parts.some((p) => p.type === "reasoning" && p.text);
const visibleMessages = messages.filter(
(message) =>
message.role === "user" ||
getTextContent(message.parts).trim() !== "" ||
message.parts.some((p) => isToolPart(p.type)) ||
hasReasoningPart(message.parts)
);
const lastMessage = messages[messages.length - 1];
const hasContent =
getTextContent(lastMessage?.parts || []).trim() !== "" ||
lastMessage?.parts.some((p) => isToolPart(p.type)) ||
hasReasoningPart(lastMessage?.parts || []);
const isStreaming = lastMessage?.role === "assistant" && hasContent;
const showLoading = isLoading && !isStreaming;
return (
<Conversation>
<ConversationContent className="mx-auto max-w-3xl px-4 pb-4">
{visibleMessages.map((message) => {
const textContent = getTextContent(message.parts);
const reasoningPart = getReasoningPart(message.parts);
const fileParts = message.parts.filter((p) => p.type === "file");
const toolParts = message.parts.filter((p) => p.type.startsWith("tool-"));
return (
<Message key={message.id} from={message.role}>
{fileParts.length > 0 && (
<MessageAttachments>
{fileParts.map((part, index) => (
<MessageAttachment
data={part}
key={`${message.id}-file-${index}`}
/>
))}
</MessageAttachments>
)}
{message.role === "assistant" && reasoningPart && (
<Reasoning isStreaming={reasoningPart.state !== "done"}>
<ReasoningTrigger />
<ReasoningContent>{reasoningPart.text || ""}</ReasoningContent>
</Reasoning>
)}
{toolParts.map((part, index) => (
<ToolPartRenderer key={`tool-${index}`} part={part} />
))}
{textContent && (
<MessageContent from={message.role}>
<MessageResponse>{textContent}</MessageResponse>
</MessageContent>
)}
</Message>
);
})}
{showLoading && <LoadingIndicator />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}
+118
View File
@@ -0,0 +1,118 @@
'use client';
import type { ChatStatus } from 'ai';
import { ChatInput, type PromptInputMessage } from './ChatInput';
import { Suggestion } from '@/components/ai-elements/suggestion';
import { RiOpenaiLine } from '@remixicon/react';
import { Shimmer } from '@/components/ai-elements/shimmer';
export interface PromptSuggestion {
key: string;
value: string;
}
interface ChatPromptProps {
value: string;
onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void;
status?: ChatStatus;
suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean;
}
const defaultSuggestions: PromptSuggestion[] = [
{
key: 'Latest AI news',
value:
'What are the latest developments in AI in 2026? Search for recent news.',
},
{
key: 'Research a company',
value: 'Research OpenAI: their latest products, funding, and news.',
},
{
key: 'Fact check',
value:
'What is the current population of the world and how is it changing?',
},
{
key: 'Compare topics',
value:
'Compare the latest iPhone vs Samsung Galaxy flagship models with sources.',
},
];
const offlineSuggestions: PromptSuggestion[] = [
{
key: 'Explain a concept',
value: 'Explain retrieval-augmented generation in plain language.',
},
{
key: 'Compare approaches',
value: 'Compare REST and GraphQL, including when to choose each.',
},
{
key: 'Draft an outline',
value: 'Draft an outline for a practical guide to evaluating AI agents.',
},
{
key: 'Brainstorm ideas',
value: 'Brainstorm useful AI agent ideas for a small business.',
},
];
export function ChatPrompt({
value,
onChange,
onSubmit,
status = 'ready',
suggestions,
webSearchEnabled = false,
}: ChatPromptProps) {
const displayedSuggestions =
suggestions ?? (webSearchEnabled ? defaultSuggestions : offlineSuggestions);
const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion);
};
return (
<div className="flex h-screen flex-col items-center justify-center gap-6 bg-muted p-4">
<div className="space-y-3 text-center">
<div className="flex items-center justify-center gap-2 text-primary">
<div className="flex items-center justify-center size-12 rounded-xl bg-black">
<RiOpenaiLine className="size-6 text-white animate-pulse" />
</div>
</div>
<Shimmer as="h1" className="text-3xl font-bold tracking-tight">
ChatGPT Agent
</Shimmer>
<p className="text-muted-foreground max-w-md mx-auto">
{webSearchEnabled
? "Ask anything — I'll search the web in real time to find accurate, up-to-date answers with sources."
: 'Ask anything — I can help research, explain, compare, and summarize.'}
</p>
</div>
<div className="w-full max-w-2xl">
<ChatInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
status={status}
/>
</div>
<div className="flex flex-wrap justify-center gap-2">
{displayedSuggestions.map((suggestion) => (
<Suggestion
key={suggestion.key}
className="bg-background text-foreground dark:bg-background dark:text-foreground"
suggestion={suggestion.key}
onClick={() => handleSuggestionClick(suggestion.value)}
/>
))}
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { PlusIcon, MoreHorizontalIcon, TrashIcon } from 'lucide-react';
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction,
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
export interface Chat {
id: string;
title: string;
created_at: string;
}
interface ChatSidebarProps {
chats: Chat[];
activeChatId: string | null;
onSelectChat: (chatId: string) => void;
onNewChat: () => void;
onDeleteChat: (chatId: string) => void;
}
export function ChatSidebar({
chats,
activeChatId,
onSelectChat,
onNewChat,
onDeleteChat,
}: ChatSidebarProps) {
return (
<Sidebar>
<SidebarHeader>
<Button
onClick={onNewChat}
variant="outline"
className="w-full justify-start gap-2"
>
<PlusIcon className="size-4" />
New Chat
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{chats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton
isActive={chat.id === activeChatId}
onClick={() => onSelectChat(chat.id)}
tooltip={chat.title}
>
<span>{chat.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontalIcon className="size-4" />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
<DropdownMenuItem
onClick={() => onDeleteChat(chat.id)}
className=""
>
<TrashIcon className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
+9
View File
@@ -0,0 +1,9 @@
export { ChatAgent } from "./ChatAgent";
export { ChatInput } from "./ChatInput";
export { ChatMessage } from "./ChatMessage";
export { ChatMessages } from "./ChatMessages";
export { ChatPrompt } from "./ChatPrompt";
export { ChatSidebar } from "./ChatSidebar";
export type { Chat } from "./ChatSidebar";
export type { Message } from "./ChatMessage";
export type { PromptSuggestion } from "./ChatPrompt";