Files
zooqueen 3957334396 Fix zoo-logo: GlobalChatWidget points at .svg + regenerate the .png
The /zoo-logo.png file in /public was actually the Hanzo "H" logo —
not the Zoo brand mark — so the chat widget's avatar + floating button
rendered as Hanzo on Zoo's site. Two changes:

- GlobalChatWidget: switch both <img src> from /zoo-logo.png to
  /zoo-logo.svg (the correct CMYK Venn diagram).
- Regenerate /zoo-logo.png from /zoo-logo.svg at 512x512 via qlmanage
  so any future code that reaches for the PNG also gets the right mark.
2026-05-18 23:17:58 -07:00

1078 lines
39 KiB
TypeScript

"use client";
import { usePathname } from "next/navigation";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageSquare,
X,
Send,
Code,
BookOpen,
Search,
PenLine,
Minimize2,
Maximize2,
ChevronDown,
Check,
Lock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import site from "@/site.config";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const API_URL = site.chat.apiUrl;
const API_KEY = process.env.NEXT_PUBLIC_LLM_API_KEY ?? "";
const IAM_AUTHORIZE_URL = site.chat.iamAuthorizeUrl;
const IAM_CLIENT_ID = site.chat.iamClientId;
const AUTH_TOKEN_KEY = "hanzo_auth_token";
const CHAT_COUNT_KEY = "hanzo_chat_count";
const FREE_MESSAGE_LIMIT = site.chat.freeMessageLimit;
interface ZenModel {
id: string;
name: string;
description: string;
params: string;
tier: "free" | "pro";
}
const freeModels: ZenModel[] = [
{ id: "zen", name: "Zen", description: "Fast & capable", params: "32B", tier: "free" },
];
const premiumModels: ZenModel[] = [
{ id: "zen5-pro", name: "Zen5 Pro", description: "80B MoE flagship", params: "80B MoE", tier: "pro" },
{ id: "zen5", name: "Zen5", description: "744B MoE frontier", params: "744B MoE", tier: "pro" },
{ id: "zen5-mini", name: "Zen5 Mini", description: "Fast 8B", params: "8B", tier: "pro" },
{ id: "zen5-coder", name: "Zen5 Coder", description: "Code specialist", params: "480B MoE", tier: "pro" },
{ id: "zen5-ultra", name: "Zen5 Ultra", description: "Deep reasoning", params: "744B MoE+CoT", tier: "pro" },
];
const chatPresets = [
{ icon: PenLine, label: "Write", prompt: "Help me write " },
{ icon: BookOpen, label: "Learn", prompt: "Explain how " },
{ icon: Code, label: "Code", prompt: "Help me write code for " },
{ icon: Search, label: "Search", prompt: "Find information about " },
];
// ---------------------------------------------------------------------------
// Page context
// ---------------------------------------------------------------------------
const getPageContext = (pathname: string): string => {
const contexts: Record<string, string> = {
"/": "Zoo Industries homepage - Frontier AI research lab",
"/about": "About Zoo Industries - Company overview and mission",
"/team": "Zoo Industries Team - Meet our leadership and experts",
"/research": "Research - AI research and publications (130+ papers)",
"/research#ai": "AI & Machine Learning Research",
"/research#crypto": "Post-Quantum Cryptography Research",
"/research#consensus": "Consensus Protocols Research",
"/services": "Services - Professional services and consulting",
"/capabilities": "Capabilities - AI capabilities overview",
"/capabilities/decentralized-ai": "Decentralized AI - Distributed AI systems",
"/solutions": "Solutions - Industry solutions and use cases",
"/pricing": "Pricing - Plans and enterprise pricing",
"/case-studies": "Research Impact - Success stories and implementations",
"/examples": "Examples - Technical demonstrations",
"/press": "Press - News and media coverage",
"/models": "Zen Models - Foundation models (600M-1T+ parameters)",
"/ai-models": "Zen Models - Foundation models and capabilities",
"/security": "Security - Security practices and compliance",
"/status": "Status - System status and uptime",
"/contact": "Contact - Get in touch with our team",
"/products/zen": "ZEN - Advanced AI orchestration platform",
"/products/koan": "KOAN - Enterprise knowledge management",
"/products/hanzo-ai": "Zoo Industries - Comprehensive AI platform",
"/products/hanzo-dx": "Zoo DX - Developer experience platform",
"/products/hanzo-ml": "Zoo ML - Machine learning operations",
"/products/hanzo-dev": "Zoo Dev - Accelerated development environment",
"/products/hanzo-team": "Zoo Team - Collaboration platform",
"/products/lux": "Lux Network - Decentralized AI compute",
"/products/zoo": "Zoo Gym - AI training infrastructure",
};
for (const [path, context] of Object.entries(contexts)) {
if (pathname.startsWith(path) && path !== "/") {
return context;
}
}
return contexts[pathname] || `Zoo Industries page: ${pathname}`;
};
// ---------------------------------------------------------------------------
// Demo mode fallback responses
// ---------------------------------------------------------------------------
const demoResponses: Record<string, string> = {
default:
"Zoo Industries builds full-stack private AI infrastructure. Our Zen model family spans 600M to 1T+ parameters across text, vision, video, audio, 3D, code, and agents. Visit [/models](/models) for the full catalog or [/contact](/contact) to talk to our team.",
models:
"The Zen model family includes:\n\n- **Zen5** (744B MoE) - Our frontier model\n- **Zen5 Pro** (80B MoE) - High-performance flagship\n- **Zen5 Ultra** (744B MoE+CoT) - Deep reasoning\n- **Zen5 Coder** (480B MoE) - Code specialist\n- **Zen5 Mini** (8B) - Fast and efficient\n\nAll models are available via the Zoo Industries platform. See [/models](/models) for details.",
pricing:
"For pricing information, visit [/pricing](/pricing) or schedule a call with our team at [/contact](/contact). We offer flexible plans for startups, enterprises, and government.",
research:
"Zoo has published 130+ research papers across AI, cryptography, and consensus protocols. Browse our research at [/research](/research).",
code:
"Zoo offers developer tools including:\n\n- **LLM Gateway** - Unified proxy for 100+ AI providers\n- **Zen Models API** - Direct access to our model family\n- **MCP Tools** - 260+ Model Context Protocol integrations\n- **SDKs** - Python, TypeScript, Go, Rust\n\nCheck out [docs.zoo.ngo](https://docs.zoo.ngo) for documentation.",
};
function getDemoResponse(userMessage: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("model") || lower.includes("zen")) return demoResponses.models;
if (lower.includes("price") || lower.includes("cost") || lower.includes("plan")) return demoResponses.pricing;
if (lower.includes("research") || lower.includes("paper")) return demoResponses.research;
if (lower.includes("code") || lower.includes("sdk") || lower.includes("api") || lower.includes("developer"))
return demoResponses.code;
return demoResponses.default;
}
// ---------------------------------------------------------------------------
// Simple markdown renderer (no external deps)
// ---------------------------------------------------------------------------
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split("\n");
const result: React.ReactNode[] = [];
let i = 0;
let key = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.trimStart().startsWith("```")) {
const lang = line.trimStart().slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
result.push(
<pre
key={key++}
className="my-2 rounded-lg bg-white/5 border border-white/10 p-3 overflow-x-auto text-xs font-mono leading-relaxed"
>
<code>{codeLines.join("\n")}</code>
</pre>
);
continue;
}
// Empty line
if (line.trim() === "") {
result.push(<div key={key++} className="h-2" />);
i++;
continue;
}
// Unordered list item
if (/^\s*[-*]\s+/.test(line)) {
const items: React.ReactNode[] = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
const content = lines[i].replace(/^\s*[-*]\s+/, "");
items.push(
<li key={key++} className="ml-4 list-disc">
{renderInline(content)}
</li>
);
i++;
}
result.push(
<ul key={key++} className="my-1 space-y-0.5">
{items}
</ul>
);
continue;
}
// Ordered list item
if (/^\s*\d+\.\s+/.test(line)) {
const items: React.ReactNode[] = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
const content = lines[i].replace(/^\s*\d+\.\s+/, "");
items.push(
<li key={key++} className="ml-4 list-decimal">
{renderInline(content)}
</li>
);
i++;
}
result.push(
<ol key={key++} className="my-1 space-y-0.5">
{items}
</ol>
);
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,3})\s+(.*)/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
const cls =
level === 1
? "text-base font-bold mt-3 mb-1"
: level === 2
? "text-sm font-semibold mt-2 mb-1"
: "text-sm font-medium mt-2 mb-0.5";
result.push(
<div key={key++} className={cls}>
{renderInline(text)}
</div>
);
i++;
continue;
}
// Regular paragraph
result.push(
<p key={key++} className="my-0.5 leading-relaxed">
{renderInline(line)}
</p>
);
i++;
}
return result;
}
function renderInline(text: string): React.ReactNode[] {
// Process inline markdown: bold, italic, inline code, links
const parts: React.ReactNode[] = [];
// Regex matches: **bold**, *italic*, `code`, [text](url)
const pattern = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
while ((match = pattern.exec(text)) !== null) {
// Push text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// Bold
parts.push(
<strong key={key++} className="font-semibold text-white">
{match[2]}
</strong>
);
} else if (match[3]) {
// Italic
parts.push(
<em key={key++} className="italic">
{match[4]}
</em>
);
} else if (match[5]) {
// Inline code
parts.push(
<code
key={key++}
className="px-1 py-0.5 rounded bg-white/10 text-white/90 text-xs font-mono"
>
{match[6]}
</code>
);
} else if (match[7]) {
// Link
parts.push(
<a
key={key++}
href={match[9]}
className="underline underline-offset-2 text-white/80 hover:text-white transition-colors"
target={match[9]?.startsWith("http") ? "_blank" : undefined}
rel={match[9]?.startsWith("http") ? "noopener noreferrer" : undefined}
>
{match[8]}
</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
isStreaming?: boolean;
}
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function getAuthToken(): string | null {
if (typeof window === "undefined") return null;
return sessionStorage.getItem(AUTH_TOKEN_KEY);
}
function isAuthenticated(): boolean {
return !!getAuthToken();
}
function getChatCount(): number {
if (typeof window === "undefined") return 0;
const raw = localStorage.getItem(CHAT_COUNT_KEY);
return raw ? parseInt(raw, 10) : 0;
}
function incrementChatCount(): number {
const count = getChatCount() + 1;
localStorage.setItem(CHAT_COUNT_KEY, String(count));
return count;
}
function buildLoginUrl(): string {
if (typeof window === "undefined") return "#";
// Strip any existing auth params from the redirect URI
const url = new URL(window.location.href);
url.hash = "";
url.searchParams.delete("access_token");
url.searchParams.delete("token_type");
url.searchParams.delete("state");
const redirectUri = url.toString();
const state = Math.random().toString(36).slice(2);
if (typeof window !== "undefined") {
sessionStorage.setItem("hanzo_oauth_state", state);
}
const params = new URLSearchParams({
client_id: IAM_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "token",
scope: "openid profile email",
state,
});
return `${IAM_AUTHORIZE_URL}?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// System prompt builder
// ---------------------------------------------------------------------------
function buildSystemPrompt(pageContext: string, model: ZenModel): string {
return `You are Zen AI, an assistant for Zoo Industries -- an AI company building full-stack private AI infrastructure. You're powered by the Zen model family. Current page: ${pageContext}.
Help users understand Zoo's products: Zen frontier models (600M-1T+ params), Zoo Industries platform, Zoo Engine (cloud GPU inference), Zoo Edge (on-device inference), LLM Gateway (100+ models), and more.
Be concise, helpful, and knowledgeable. For pricing or sales, direct to /pricing or /contact. For documentation, suggest docs.zoo.ngo.`;
}
// ---------------------------------------------------------------------------
// SSE stream parser
// ---------------------------------------------------------------------------
async function* parseSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: TextDecoder
): AsyncGenerator<string, void, unknown> {
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last partial line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === "data: [DONE]") continue;
if (!trimmed.startsWith("data: ")) continue;
try {
const json = JSON.parse(trimmed.slice(6));
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
yield delta;
}
} catch {
// Skip malformed JSON chunks
}
}
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function GlobalChatWidget() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showLoginGate, setShowLoginGate] = useState(false);
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [authed, setAuthed] = useState(false);
const allModels = authed ? [...freeModels, ...premiumModels] : freeModels;
const [selectedModel, setSelectedModel] = useState<ZenModel>(freeModels[0]);
const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const isDemoMode = !API_KEY;
const pageContext = getPageContext(pathname);
// ---- OAuth callback token capture + auth state init ----
useEffect(() => {
if (typeof window === "undefined") return;
let token: string | null = null;
// Check URL hash for implicit flow token: #access_token=...&token_type=bearer
if (window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const hashToken = hashParams.get("access_token");
if (hashToken) {
// Validate state if present
const returnedState = hashParams.get("state");
const savedState = sessionStorage.getItem("hanzo_oauth_state");
if (savedState && returnedState === savedState) {
token = hashToken;
sessionStorage.removeItem("hanzo_oauth_state");
}
}
}
// Note: query-param token fallback removed for security — tokens in query strings
// are logged by CDNs, analytics, and browser history. The zoo.id-worker bridge
// flow must deliver tokens via hash fragment only.
// Store token and clean URL
if (token) {
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
// Reset chat count so authenticated user gets unlimited
localStorage.removeItem(CHAT_COUNT_KEY);
const url = new URL(window.location.href);
url.hash = "";
url.searchParams.delete("access_token");
url.searchParams.delete("token_type");
url.searchParams.delete("expires_in");
url.searchParams.delete("state");
url.searchParams.delete("provider");
url.searchParams.delete("status");
window.history.replaceState({}, "", url.toString());
// Auto-open chat after login
setIsOpen(true);
}
setAuthed(isAuthenticated());
}, []);
// ---- Close model dropdown on outside click ----
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modelDropdownRef.current &&
!modelDropdownRef.current.contains(event.target as Node)
) {
setIsModelDropdownOpen(false);
}
};
if (isModelDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isModelDropdownOpen]);
// ---- Listen for openGlobalChat custom event ----
useEffect(() => {
const handleOpenChat = (event: CustomEvent) => {
setIsOpen(true);
if (event.detail?.message) {
setInput(event.detail.message);
setTimeout(() => inputRef.current?.focus(), 100);
}
if (event.detail?.action) {
const preset = chatPresets.find((p) => p.label === event.detail.action);
if (preset) {
setInput(preset.prompt);
setTimeout(() => inputRef.current?.focus(), 100);
}
}
};
window.addEventListener("openGlobalChat", handleOpenChat as EventListener);
return () =>
window.removeEventListener(
"openGlobalChat",
handleOpenChat as EventListener
);
}, []);
// ---- Auto-scroll on new messages ----
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// ---- Focus input when opened ----
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// ---- Welcome message ----
useEffect(() => {
if (isOpen && messages.length === 0) {
setMessages([
{
id: "welcome",
role: "assistant",
content: `Welcome to Zen AI. You are currently viewing ${pageContext}. How can I help you?`,
timestamp: new Date(),
},
]);
}
}, [isOpen, pageContext]);
// ---- Cleanup abort controller on unmount ----
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
// ---- Send message ----
const handleSend = useCallback(async () => {
if (!input.trim() || isLoading) return;
// Login gate check: if not authed and already used free exchange
if (!authed && getChatCount() >= FREE_MESSAGE_LIMIT) {
setShowLoginGate(true);
return;
}
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
const currentInput = input.trim();
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
setShowLoginGate(false);
// Track usage
if (!authed) {
incrementChatCount();
}
// Demo mode: return canned response
if (isDemoMode) {
const response = getDemoResponse(currentInput);
// Simulate typing delay
await new Promise((r) => setTimeout(r, 600));
setMessages((prev) => [
...prev,
{
id: (Date.now() + 1).toString(),
role: "assistant",
content: response,
timestamp: new Date(),
},
]);
setIsLoading(false);
return;
}
// Live mode: streaming SSE
const assistantId = (Date.now() + 1).toString();
setMessages((prev) => [
...prev,
{
id: assistantId,
role: "assistant",
content: "",
timestamp: new Date(),
isStreaming: true,
},
]);
const abortController = new AbortController();
abortRef.current = abortController;
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
// Use user auth token if available, otherwise API key
const authToken = getAuthToken();
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
} else {
headers["Authorization"] = `Bearer ${API_KEY}`;
}
const conversationHistory = messages
.filter((m) => m.id !== "welcome")
.slice(-10)
.map((m) => ({ role: m.role, content: m.content }));
const response = await fetch(`${API_URL}/v1/chat/completions`, {
method: "POST",
headers,
signal: abortController.signal,
body: JSON.stringify({
model: selectedModel.id,
messages: [
{ role: "system", content: buildSystemPrompt(pageContext, selectedModel) },
...conversationHistory,
{ role: "user", content: currentInput },
],
stream: true,
max_tokens: 1024,
temperature: 0.7,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`API error ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
for await (const chunk of parseSSEStream(reader, decoder)) {
accumulated += chunk;
const snapshot = accumulated;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: snapshot }
: m
)
);
}
// Mark streaming complete
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, isStreaming: false }
: m
)
);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
// User cancelled -- leave partial content
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, isStreaming: false }
: m
)
);
} else {
// Replace empty streaming message with error
const errorContent =
"I'm having trouble connecting right now. You can try [zoo.chat](https://zoo.chat) for the full experience, or visit [docs.zoo.ngo](https://docs.zoo.ngo) for documentation.";
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: errorContent, isStreaming: false }
: m
)
);
}
} finally {
abortRef.current = null;
setIsLoading(false);
}
}, [input, isLoading, pageContext, messages, selectedModel, authed, isDemoMode]);
const handlePreset = (preset: (typeof chatPresets)[0]) => {
setInput(preset.prompt);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// ---- Render ----
return (
<>
{/* Floating chat button */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg flex items-center justify-center bg-black border border-white/10"
>
<img src="/zoo-logo.svg" alt="Zoo" className="w-8 h-8" />
</motion.button>
)}
</AnimatePresence>
{/* Chat window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={cn(
"fixed z-50 rounded-2xl shadow-2xl overflow-hidden flex flex-col",
"bg-neutral-950 border border-white/10",
isExpanded
? "inset-4 md:inset-8"
: "bottom-6 right-6 w-[400px] max-w-[calc(100vw-48px)] h-[560px] max-h-[80vh]"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-black/50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-black border border-white/10 overflow-hidden">
<img src="/zoo-logo.svg" alt="Zoo" className="w-5 h-5" />
</div>
{/* Model selector */}
<div className="relative" ref={modelDropdownRef}>
<button
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
className="flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors hover:bg-white/5"
>
<div className="text-left">
<div className="text-sm font-medium flex items-center gap-1.5 text-white">
Zen AI
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/10 text-neutral-400">
{selectedModel.name}
</span>
<span
className={cn(
"text-[9px] font-medium uppercase tracking-wider px-1 py-0.5 rounded",
selectedModel.tier === "free"
? "bg-white/10 text-neutral-400"
: "bg-white/15 text-neutral-300"
)}
>
{selectedModel.tier === "free" ? "Free" : "Pro"}
</span>
</div>
</div>
<ChevronDown
className={cn(
"w-3.5 h-3.5 transition-transform text-neutral-500",
isModelDropdownOpen && "rotate-180"
)}
/>
</button>
{/* Model dropdown */}
<AnimatePresence>
{isModelDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 w-64 border border-white/10 rounded-lg shadow-xl overflow-hidden z-10 bg-neutral-900"
>
{allModels.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model);
setIsModelDropdownOpen(false);
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 text-left transition-colors hover:bg-white/5",
selectedModel.id === model.id && "bg-white/5"
)}
>
<div>
<div className="text-sm flex items-center gap-2 text-white">
{model.name}
<span className="text-[10px] font-mono text-neutral-500">
{model.params}
</span>
<span
className={cn(
"text-[9px] font-medium uppercase tracking-wider px-1 py-0.5 rounded",
model.tier === "free"
? "bg-white/10 text-neutral-400"
: "bg-white/15 text-neutral-300"
)}
>
{model.tier === "free" ? "Free" : "Pro"}
</span>
</div>
<div className="text-[10px] text-neutral-500 mt-0.5">
{model.description}
</div>
</div>
{selectedModel.id === model.id && (
<Check className="w-4 h-4 text-neutral-400 shrink-0" />
)}
</button>
))}
<div className="border-t border-white/10 px-3 py-2">
<a
href="/models"
className="text-xs text-neutral-500 hover:text-white transition-colors"
>
View all 41+ models
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 rounded-md transition-colors text-neutral-500 hover:text-white hover:bg-white/5"
>
{isExpanded ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
<button
onClick={() => {
setIsOpen(false);
abortRef.current?.abort();
}}
className="p-1.5 rounded-md transition-colors text-neutral-500 hover:text-white hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Demo mode banner */}
{isDemoMode && (
<div className="px-4 py-1.5 bg-white/5 border-b border-white/5 text-center">
<span className="text-[10px] text-neutral-500">
Demo mode -- AI responses coming soon
</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 relative">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[85%] px-3 py-2 rounded-2xl text-sm",
message.role === "user"
? "rounded-br-md bg-white text-black"
: "rounded-bl-md bg-white/5 text-neutral-300"
)}
>
{message.role === "assistant" ? (
<div className="space-y-0">
{renderMarkdown(message.content)}
{message.isStreaming && (
<span className="inline-block w-1.5 h-4 ml-0.5 bg-neutral-400 animate-pulse" />
)}
</div>
) : (
message.content
)}
</div>
</div>
))}
{/* Loading indicator (only when no streaming message is visible) */}
{isLoading &&
!messages.some((m) => m.isStreaming) && (
<div className="flex justify-start">
<div className="px-4 py-2 rounded-2xl rounded-bl-md bg-white/5">
<div className="flex gap-1">
<span
className="w-1.5 h-1.5 rounded-full bg-neutral-500 animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-neutral-500 animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-neutral-500 animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
{/* Login gate overlay */}
<AnimatePresence>
{showLoginGate && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="mx-6 p-6 rounded-xl bg-neutral-900 border border-white/10 text-center max-w-[300px]"
>
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
<Lock className="w-5 h-5 text-neutral-400" />
</div>
<h3 className="text-base font-semibold text-white mb-2">
Sign in to continue
</h3>
<p className="text-xs text-neutral-400 mb-5 leading-relaxed">
You have used your free message. Sign in to unlock
unlimited chat and access premium Zen models.
</p>
<a
href={buildLoginUrl()}
className="inline-block w-full px-4 py-2.5 rounded-lg bg-white text-black text-sm font-medium hover:bg-neutral-200 transition-colors"
>
Sign in with Zoo
</a>
<button
onClick={() => setShowLoginGate(false)}
className="mt-3 text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
Dismiss
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Preset buttons */}
{messages.length <= 1 && !showLoginGate && (
<div className="px-4 pb-2">
<div className="flex flex-wrap gap-2">
{chatPresets.map((preset) => {
const Icon = preset.icon;
return (
<button
key={preset.label}
onClick={() => handlePreset(preset)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs font-medium transition-colors bg-white/5 border-white/10 text-neutral-400 hover:bg-white/10 hover:text-white"
>
<Icon className="w-3 h-3" />
{preset.label}
</button>
);
})}
</div>
</div>
)}
{/* Input */}
<div className="p-3 border-t border-white/10">
<div className="relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={showLoginGate ? "Sign in to continue..." : "Ask anything..."}
disabled={showLoginGate}
className={cn(
"w-full rounded-full px-4 py-2.5 pr-12 text-sm focus:outline-none transition-colors border",
"bg-white/5 border-white/10 text-white placeholder-neutral-500",
"focus:border-white/20",
showLoginGate && "opacity-50 cursor-not-allowed"
)}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading || showLoginGate}
className={cn(
"absolute right-1.5 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center transition-all disabled:opacity-30",
input.trim() ? "bg-white" : "bg-transparent"
)}
>
<Send
className={cn(
"w-4 h-4",
input.trim() ? "text-black" : "text-neutral-500"
)}
/>
</button>
</div>
<div className="mt-2 text-center">
<span className="text-[10px] text-neutral-600">
{authed
? "Press Enter to send"
: `${Math.max(0, FREE_MESSAGE_LIMIT - getChatCount())} free message${FREE_MESSAGE_LIMIT - getChatCount() !== 1 ? "s" : ""} remaining`}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}