ai_api / src /app /page.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
25.2 kB
"use client";
import React, { useState, useRef, useEffect } from "react";
interface Message {
role: "user" | "assistant" | "system";
content: string | any[];
reasoning_content?: string;
files?: Array<{ name: string; type: string; dataUrl?: string }>;
}
export default function AetherisChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [selectedModel, setSelectedModel] = useState("auto/best-coding");
const [isGenerating, setIsGenerating] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<
Array<{ name: string; type: string; dataUrl?: string; textContent?: string }>
>([]);
const [showModelsDropdown, setShowModelsDropdown] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const models = [
{ id: "auto/best-coding", name: "Best Coding", desc: "Top-tier coding, logic, and debugging" },
{
id: "auto/best-reasoning",
name: "Best Reasoning",
desc: "Collapsible thinking, logic, math",
},
{ id: "auto/best-free", name: "Best Free", desc: "100% free, unlimited scaling models" },
{
id: "auto/best-chat",
name: "Best Conversational",
desc: "Fast, natural dialogues and answers",
},
];
// Auto-scroll to bottom of chat
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isGenerating]);
// Auto-resize textarea height
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
}, [inputValue]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file) => {
const reader = new FileReader();
if (file.type.startsWith("image/")) {
reader.onload = (event) => {
setAttachedFiles((prev) => [
...prev,
{ name: file.name, type: file.type, dataUrl: event.target?.result as string },
]);
};
reader.readAsDataURL(file);
} else {
// Read text files directly as text content
reader.onload = (event) => {
setAttachedFiles((prev) => [
...prev,
{ name: file.name, type: file.type, textContent: event.target?.result as string },
]);
};
reader.readAsText(file);
}
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removeAttachedFile = (index: number) => {
setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
};
const executeQuickAction = (promptText: string) => {
if (isGenerating) return;
sendMessage(promptText, []);
};
const handleSend = () => {
if (isGenerating) return;
const text = inputValue.trim();
if (!text && attachedFiles.length === 0) return;
sendMessage(text, attachedFiles);
setInputValue("");
setAttachedFiles([]);
};
const sendMessage = async (text: string, files: typeof attachedFiles) => {
setIsGenerating(true);
// Prepare message payload content
let userMsgContent: string | any[] = text;
// Check if there are attached images for multimodal OpenAI compatibility
const attachedImages = files.filter((f) => f.type.startsWith("image/"));
const attachedTexts = files.filter((f) => !f.type.startsWith("image/"));
if (attachedImages.length > 0) {
const parts: any[] = [{ type: "text", text }];
attachedImages.forEach((img) => {
parts.push({
type: "image_url",
image_url: { url: img.dataUrl },
});
});
userMsgContent = parts;
} else if (attachedTexts.length > 0) {
// Append text file content straight to the prompt
let textFilesPrompt = "";
attachedTexts.forEach((file) => {
textFilesPrompt += `[Attached File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\`\n\n`;
});
userMsgContent = textFilesPrompt + text;
}
const newUserMessage: Message = {
role: "user",
content: userMsgContent,
files: files.map((f) => ({ name: f.name, type: f.type, dataUrl: f.dataUrl })),
};
const newMessages = [...messages, newUserMessage];
setMessages(newMessages);
// Create a placeholder for the assistant response
const assistantMessageIndex = newMessages.length;
setMessages((prev) => [...prev, { role: "assistant", content: "", reasoning_content: "" }]);
try {
// Structure chat messages list for completions payload
const chatHistory = newMessages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
const response = await fetch("/api/public-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
messages: chatHistory,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`Server returned error: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("ReadableStream is not supported by the response.");
let assistantText = "";
let reasoningText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const dataStr = line.slice(6).trim();
if (dataStr === "[DONE]") continue;
try {
const dataObj = JSON.parse(dataStr);
const delta = dataObj.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
reasoningText += delta.reasoning_content;
}
if (delta.content) {
assistantText += delta.content;
}
setMessages((prev) => {
const updated = [...prev];
updated[assistantMessageIndex] = {
role: "assistant",
content: assistantText,
reasoning_content: reasoningText,
};
return updated;
});
}
} catch {
// Ignore parse errors from malformed SSE chunks
}
}
}
}
} catch (err: any) {
console.error(err);
setMessages((prev) => {
const updated = [...prev];
updated[assistantMessageIndex] = {
role: "assistant",
content: `⚠️ Error: ${err.message || "Failed to generate response"}`,
};
return updated;
});
} finally {
setIsGenerating(false);
}
};
// Helper to parse markdown-like code blocks and lists
const renderMessageContent = (msg: Message) => {
let text = "";
if (typeof msg.content === "string") {
text = msg.content;
} else if (Array.isArray(msg.content)) {
const textPart = msg.content.find((p: any) => p.type === "text");
text = textPart ? textPart.text : "";
}
if (!text) return null;
// Split content by code blocks
const parts = text.split(/(```[\s\S]*?```)/g);
return parts.map((part, idx) => {
if (part.startsWith("```") && part.endsWith("```")) {
const lines = part.split("\n");
const header = lines[0].slice(3).trim();
const code = lines.slice(1, -1).join("\n");
return (
<div
key={idx}
className="my-4 border border-zinc-800 rounded-lg overflow-hidden bg-zinc-950 font-mono text-sm"
>
<div className="flex justify-between items-center px-4 py-2 bg-zinc-900 border-b border-zinc-800 text-zinc-400 select-none">
<span>{header || "code"}</span>
<button
onClick={() => navigator.clipboard.writeText(code)}
className="flex items-center gap-1 hover:text-zinc-200 transition-colors text-xs"
>
<span className="material-symbols-outlined text-sm">content_copy</span> Copy
</button>
</div>
<pre className="p-4 overflow-x-auto text-zinc-300">
<code>{code}</code>
</pre>
</div>
);
}
// Handle simple formatting like lists and paragraphs
return (
<div key={idx} className="whitespace-pre-wrap leading-relaxed break-words">
{part.split("\n").map((line, lIdx) => {
if (line.startsWith("- ") || line.startsWith("* ")) {
return (
<ul key={lIdx} className="list-disc pl-6 my-1">
<li>{line.slice(2)}</li>
</ul>
);
}
if (/^\d+\.\s/.test(line)) {
const numEnd = line.indexOf(" ");
return (
<ol key={lIdx} className="list-decimal pl-6 my-1">
<li>{line.slice(numEnd + 1)}</li>
</ol>
);
}
return (
<p key={lIdx} className={line.trim() === "" ? "h-2" : "my-1"}>
{line}
</p>
);
})}
</div>
);
});
};
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col font-sans">
{/* Premium Glassmorphic Header */}
<header className="sticky top-0 z-40 bg-zinc-950/70 backdrop-blur-xl border-b border-zinc-900 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-500 via-purple-500 to-pink-500 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<span className="material-symbols-outlined text-white text-2xl font-semibold select-none">
auto_awesome
</span>
</div>
<div>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-zinc-100 via-zinc-200 to-purple-400 bg-clip-text text-transparent">
Aetheris AI
</h1>
<p className="text-[10px] text-zinc-500 font-mono tracking-wide uppercase">
Unified Router
</p>
</div>
</div>
{/* Model Selection Dropdown */}
<div className="relative">
<button
onClick={() => setShowModelsDropdown(!showModelsDropdown)}
className="flex items-center gap-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 hover:bg-zinc-800/80 px-4 py-2 rounded-xl text-sm transition-all shadow-sm cursor-pointer select-none"
>
<span className="material-symbols-outlined text-purple-400 text-lg">smart_toy</span>
<span className="font-semibold text-zinc-300">
{models.find((m) => m.id === selectedModel)?.name}
</span>
<span className="material-symbols-outlined text-zinc-500 text-sm">
{showModelsDropdown ? "keyboard_arrow_up" : "keyboard_arrow_down"}
</span>
</button>
{showModelsDropdown && (
<div className="absolute right-0 mt-2 w-72 bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden z-50 py-1">
{models.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model.id);
setShowModelsDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-zinc-800 flex flex-col transition-colors cursor-pointer"
>
<span className="font-semibold text-zinc-200 text-sm">{model.name}</span>
<span className="text-xs text-zinc-500 mt-0.5">{model.desc}</span>
</button>
))}
</div>
)}
</div>
</header>
{/* Main Chat Interface */}
<main className="flex-1 overflow-y-auto px-4 md:px-8 py-6 max-w-4xl mx-auto w-full flex flex-col justify-between">
{messages.length === 0 ? (
/* Empty / Landing Hero State */
<div className="flex-1 flex flex-col items-center justify-center py-20">
<div className="w-24 h-24 rounded-3xl bg-gradient-to-tr from-indigo-500 via-purple-500 to-pink-500 flex items-center justify-center shadow-2xl shadow-indigo-500/25 mb-8 animate-pulse">
<span className="material-symbols-outlined text-white text-6xl font-semibold select-none">
auto_awesome
</span>
</div>
<h2 className="text-4xl font-extrabold tracking-tight text-center bg-gradient-to-r from-zinc-50 via-zinc-100 to-purple-300 bg-clip-text text-transparent">
How can Aetheris help you?
</h2>
<p className="text-zinc-400 mt-3 text-center max-w-md text-sm leading-relaxed">
Experience unified intelligence routing across multiple AI models dynamically. Upload
code, text, or ask a question.
</p>
{/* Quick Action Suggestion Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-12 w-full max-w-3xl">
<button
onClick={() =>
executeQuickAction(
"Write a clean, responsive landing page using HTML and Tailwind CSS."
)
}
className="bg-zinc-900/40 border border-zinc-900 hover:border-zinc-800 hover:bg-zinc-900/80 p-5 rounded-2xl text-left transition-all hover:scale-[1.02] flex flex-col items-start cursor-pointer"
>
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center mb-3">
<span className="material-symbols-outlined text-indigo-400 text-xl">code</span>
</div>
<span className="font-semibold text-zinc-200 text-sm">Write Code</span>
<span className="text-xs text-zinc-500 mt-2">
Write a clean landing page with Tailwind CSS.
</span>
</button>
<button
onClick={() =>
executeQuickAction(
"Design a SQL schema for a modern e-commerce checkout flow with order history."
)
}
className="bg-zinc-900/40 border border-zinc-900 hover:border-zinc-800 hover:bg-zinc-900/80 p-5 rounded-2xl text-left transition-all hover:scale-[1.02] flex flex-col items-start cursor-pointer"
>
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center mb-3">
<span className="material-symbols-outlined text-purple-400 text-xl">
database
</span>
</div>
<span className="font-semibold text-zinc-200 text-sm">DB Schema</span>
<span className="text-xs text-zinc-500 mt-2">
Design a SQL schema for a checkout flow.
</span>
</button>
<button
onClick={() =>
executeQuickAction(
"Explain how quantum computing and superposition work using a simple coin analogy."
)
}
className="bg-zinc-900/40 border border-zinc-900 hover:border-zinc-800 hover:bg-zinc-900/80 p-5 rounded-2xl text-left transition-all hover:scale-[1.02] flex flex-col items-start cursor-pointer"
>
<div className="w-8 h-8 rounded-lg bg-pink-500/10 flex items-center justify-center mb-3">
<span className="material-symbols-outlined text-pink-400 text-xl">school</span>
</div>
<span className="font-semibold text-zinc-200 text-sm">Explain Concepts</span>
<span className="text-xs text-zinc-500 mt-2">
Explain quantum computing using a coin analogy.
</span>
</button>
</div>
</div>
) : (
/* Messages Feed */
<div className="flex-1 flex flex-col gap-6 py-4">
{messages.map((msg, index) => (
<div key={index} className="flex flex-col gap-2">
<div
className={`flex gap-4 items-start ${msg.role === "user" ? "justify-end" : ""}`}
>
{msg.role !== "user" && (
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-indigo-500 to-purple-500 flex items-center justify-center shadow-md select-none shrink-0">
<span className="material-symbols-outlined text-white text-lg">
auto_awesome
</span>
</div>
)}
<div
className={`max-w-[85%] rounded-2xl p-4 text-[15px] shadow-sm flex flex-col gap-2 ${
msg.role === "user"
? "bg-purple-600/10 border border-purple-500/20 text-purple-50"
: "bg-zinc-900/60 border border-zinc-900 text-zinc-100"
}`}
>
{/* Attached files preview in history */}
{msg.files && msg.files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{msg.files.map((file, fIdx) => (
<div
key={fIdx}
className="flex items-center gap-1.5 bg-zinc-950 border border-zinc-800 rounded-lg p-1.5 pr-2.5 text-xs select-none"
>
{file.type.startsWith("image/") ? (
<img
src={file.dataUrl}
className="w-8 h-8 object-cover rounded"
alt="uploaded"
/>
) : (
<span className="material-symbols-outlined text-zinc-500 text-base">
description
</span>
)}
<span className="text-zinc-400 truncate max-w-[120px] font-mono">
{file.name}
</span>
</div>
))}
</div>
)}
{/* Rendering thinking process block for assistant responses */}
{msg.role === "assistant" && msg.reasoning_content && (
<details open className="group border-l-2 border-purple-500/30 pl-3 my-1">
<summary className="text-xs text-purple-400 font-semibold cursor-pointer select-none outline-none hover:text-purple-300 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full group-open:animate-none animate-ping"></span>
Thinking Process
</summary>
<div className="text-xs text-zinc-500 font-mono mt-2 bg-zinc-950/40 p-2.5 rounded-lg border border-zinc-900/60 whitespace-pre-wrap leading-relaxed">
{msg.reasoning_content}
</div>
</details>
)}
{renderMessageContent(msg)}
</div>
{msg.role === "user" && (
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center shrink-0 select-none">
<span className="material-symbols-outlined text-zinc-400 text-lg">
person
</span>
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</main>
{/* Floating Input Panel */}
<footer className="w-full max-w-4xl mx-auto px-4 pb-6 mt-auto">
<div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-800 rounded-2xl overflow-hidden shadow-xl p-3 flex flex-col gap-2">
{/* File Previews List inside the input container */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pb-2 border-b border-zinc-800/60">
{attachedFiles.map((file, idx) => (
<div
key={idx}
className="relative group flex items-center gap-2 bg-zinc-950 border border-zinc-800 rounded-xl p-2 text-xs select-none"
>
{file.type.startsWith("image/") ? (
<img
src={file.dataUrl}
className="w-10 h-10 object-cover rounded-lg"
alt="preview"
/>
) : (
<span className="material-symbols-outlined text-zinc-400 text-2xl">
description
</span>
)}
<div className="flex flex-col truncate max-w-[120px]">
<span className="text-zinc-300 font-mono truncate">{file.name}</span>
<span className="text-[10px] text-zinc-500 font-mono">
{(file.type.split("/")[1] || "text").toUpperCase()}
</span>
</div>
<button
onClick={() => removeAttachedFile(idx)}
className="absolute -top-1.5 -right-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-full w-5 h-5 flex items-center justify-center border border-zinc-700 shadow-md cursor-pointer transition-transform group-hover:scale-110"
>
<span className="material-symbols-outlined text-[10px] font-bold">close</span>
</button>
</div>
))}
</div>
)}
{/* Prompt Form */}
<div className="flex items-end gap-2.5">
<button
onClick={() => fileInputRef.current?.click()}
className="p-2.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/80 rounded-xl transition-all cursor-pointer select-none"
title="Attach images or code files"
>
<span className="material-symbols-outlined text-2xl">attach_file</span>
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
multiple
accept="image/*,.txt,.md,.js,.ts,.json,.csv,.py,.sh,.css,.html"
/>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Ask Aetheris anything..."
className="flex-1 bg-transparent border-0 outline-none text-zinc-100 text-sm py-2 px-1 resize-none placeholder-zinc-500 max-h-48 min-h-[36px]"
rows={1}
/>
<button
onClick={handleSend}
disabled={isGenerating || (!inputValue.trim() && attachedFiles.length === 0)}
className={`p-2.5 rounded-xl transition-all flex items-center justify-center select-none cursor-pointer ${
isGenerating || (!inputValue.trim() && attachedFiles.length === 0)
? "bg-zinc-800 text-zinc-600"
: "bg-purple-600 hover:bg-purple-500 text-white shadow-md shadow-purple-500/10 active:scale-95"
}`}
>
{isGenerating ? (
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
) : (
<span className="material-symbols-outlined text-xl">send</span>
)}
</button>
</div>
</div>
<p className="text-[10px] text-zinc-600 text-center mt-2.5 font-mono select-none">
Aetheris AI is powered by OmniRoute &bull; Free keyless and active load-balanced
connections
</p>
</footer>
</div>
);
}