Spaces:
Runtime error
Runtime error
| "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 • Free keyless and active load-balanced | |
| connections | |
| </p> | |
| </footer> | |
| </div> | |
| ); | |
| } | |