Files

982 lines
35 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 '@hanzo/ui'
import site from "@/site.config";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const API_URL = site.chat.apiUrl;
const API_KEY = process.env.NEXT_PUBLIC_LLM_API_KEY ?? "";
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 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> = {
"/": "Hanzo Industries homepage - Frontier AI research lab",
"/about": "About Hanzo Industries - Company overview and mission",
"/team": "Hanzo 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": "Hanzo AI - Comprehensive AI platform",
"/products/hanzo-dx": "Hanzo DX - Developer experience platform",
"/products/hanzo-ml": "Hanzo ML - Machine learning operations",
"/products/hanzo-dev": "Hanzo Dev - Accelerated development environment",
"/products/hanzo-team": "Hanzo 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] || `Hanzo Industries page: ${pathname}`;
};
// ---------------------------------------------------------------------------
// Demo mode fallback responses
// ---------------------------------------------------------------------------
const demoResponses: Record<string, string> = {
default:
"Hanzo 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- **Zen4** (744B MoE) - Our frontier model\n- **Zen4 Pro** (80B MoE) - High-performance flagship\n- **Zen4 Ultra** (744B MoE+CoT) - Deep reasoning\n- **Zen4 Coder** (480B MoE) - Code specialist\n- **Zen4 Mini** (8B) - Fast and efficient\n\nAll models are available via the Hanzo AI 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:
"Hanzo has published 130+ research papers across AI, cryptography, and consensus protocols. Browse our research at [/research](/research).",
code:
"Hanzo 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.hanzo.ai](https://docs.hanzo.ai) 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="hz-card hz-card-tight hz-mt-2 hz-mb-2 hz-scroll-x hz-t-xs hz-mono hz-leading-relaxed"
>
<code>{codeLines.join("\n")}</code>
</pre>
);
continue;
}
// Empty line
if (line.trim() === "") {
result.push(<div key={key++} className="hz-bh-1" />);
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="hz-ml-4">
{renderInline(content)}
</li>
);
i++;
}
result.push(
<ul key={key++} className="hz-mt-1 hz-mb-1 hz-stack-1">
{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="hz-ml-4">
{renderInline(content)}
</li>
);
i++;
}
result.push(
<ol key={key++} className="hz-mt-1 hz-mb-1 hz-stack-1">
{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
? "hz-t-base hz-w-bold hz-mt-3 hz-mb-1"
: level === 2
? "hz-t-sm hz-w-semibold hz-mt-2 hz-mb-1"
: "hz-t-sm hz-w-medium hz-mt-2 hz-mb-1";
result.push(
<div key={key++} className={cls}>
{renderInline(text)}
</div>
);
i++;
continue;
}
// Regular paragraph
result.push(
<p key={key++} className="hz-mt-1 hz-mb-1 hz-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="hz-w-semibold hz-fg">
{match[2]}
</strong>
);
} else if (match[3]) {
// Italic
parts.push(
<em key={key++} className="hz-italic">
{match[4]}
</em>
);
} else if (match[5]) {
// Inline code
parts.push(
<code
key={key++}
className="hz-px-1 hz-py-1 hz-r-md hz-bg-quiet hz-fg-soft hz-t-xs hz-mono"
>
{match[6]}
</code>
);
} else if (match[7]) {
// Link
parts.push(
<a
key={key++}
href={match[9]}
className="hz-underline hz-fg-soft hz-transition hz-hoverable"
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;
}
// ---------------------------------------------------------------------------
// Free-message count
// ---------------------------------------------------------------------------
// The widget signs no one in. Past the free message it sends the visitor to
// Try Hanzo (site.try), hanzo.ai's own sign-in, where the full chat lives.
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;
}
// ---------------------------------------------------------------------------
// System prompt builder
// ---------------------------------------------------------------------------
function buildSystemPrompt(pageContext: string, model: ZenModel): string {
return `You are Zen AI, an assistant for Hanzo Industries -- a Techstars-backed AI company building full-stack private AI infrastructure. You're powered by the Zen model family. Current page: ${pageContext}.
Help users understand Hanzo's products: Zen frontier models (600M-1T+ params), Hanzo AI platform, Hanzo Engine (cloud GPU inference), Hanzo 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.hanzo.ai.`;
}
// ---------------------------------------------------------------------------
// 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 [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);
// ---- 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;
// Past the free exchange, the gate sends the visitor to Try Hanzo.
if (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);
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",
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 [hanzo.chat](https://hanzo.chat) for the full experience, or visit [docs.hanzo.ai](https://docs.hanzo.ai) 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, 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="hz-sq-7 hz-dock hz-r-full hz-shadow-lg hz-row hz-ai-center hz-jc-center hz-bg hz-bordered"
>
<img src="/zen-logo.png" alt="Zen AI" className="hz-sq-5 hz-ink-black" />
</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(
"hz-dock hz-r-xl hz-shadow-lg hz-clip hz-col",
// `hz-bg`, not `hz-bg-surface`: on dark `--surface-card` is
// rgba(38,38,38,.5) — fine for a card sitting ON the page, wrong
// for a panel floating OVER it, which showed the hero text
// straight through itself. The border and shadow still raise it.
"hz-bg hz-bordered",
isExpanded
? "hz-dock-panel-lg"
: "hz-dock-panel"
)}
>
{/* Header */}
<div className="hz-row hz-ai-center hz-jc-between hz-px-4 hz-py-3 hz-border-b hz-bg-surface">
<div className="hz-row hz-ai-center hz-gap-3">
<div className="hz-sq-5 hz-r-full hz-row hz-ai-center hz-jc-center hz-bg hz-bordered hz-clip">
<img src="/zen-logo.png" alt="Zen AI" className="hz-sq-3 hz-ink-black" />
</div>
{/* Model selector */}
<div className="hz-rel" ref={modelDropdownRef}>
<button
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
className="hz-row hz-ai-center hz-gap-2 hz-r-md hz-px-2 hz-py-1 hz-transition hz-hoverable"
>
<div className="hz-align-left">
<div className="hz-t-sm hz-w-medium hz-row hz-ai-center hz-gap-2 hz-fg">
Zen AI
<span className="hz-t-xs hz-mono hz-px-2 hz-py-1 hz-r-md hz-bg-quiet hz-fg-muted">
{selectedModel.name}
</span>
<span
className={cn(
"hz-t-xs hz-w-medium hz-upper hz-tracking-wide hz-px-1 hz-py-1 hz-r-md",
selectedModel.tier === "free"
? "hz-bg-quiet hz-fg-muted"
: "hz-bg-quiet hz-fg-soft"
)}
>
{selectedModel.tier === "free" ? "Free" : "Pro"}
</span>
</div>
</div>
<ChevronDown
className={cn(
"hz-sq-2 hz-transition hz-fg-muted",
isModelDropdownOpen && ""
)}
/>
</button>
{/* Model dropdown */}
<AnimatePresence>
{isModelDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="hz-abs hz-left-0 hz-mt-1 hz-bw-8 hz-bordered hz-r-lg hz-shadow-lg hz-clip hz-z-raised hz-bg-surface"
>
{freeModels.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model);
setIsModelDropdownOpen(false);
}}
className={cn(
"hz-w-full hz-row hz-ai-center hz-jc-between hz-px-3 hz-py-2 hz-align-left hz-transition hz-hoverable",
selectedModel.id === model.id && "hz-bg-quiet"
)}
>
<div>
<div className="hz-t-sm hz-row hz-ai-center hz-gap-2 hz-fg">
{model.name}
<span className="hz-t-xs hz-mono hz-fg-muted">
{model.params}
</span>
<span
className={cn(
"hz-t-xs hz-w-medium hz-upper hz-tracking-wide hz-px-1 hz-py-1 hz-r-md",
model.tier === "free"
? "hz-bg-quiet hz-fg-muted"
: "hz-bg-quiet hz-fg-soft"
)}
>
{model.tier === "free" ? "Free" : "Pro"}
</span>
</div>
<div className="hz-t-xs hz-fg-muted hz-mt-1">
{model.description}
</div>
</div>
{selectedModel.id === model.id && (
<Check className="hz-sq-2 hz-fg-muted hz-none" />
)}
</button>
))}
<div className="hz-border-t hz-px-3 hz-py-2">
<a
href="/models"
className="hz-t-xs hz-fg-muted hz-transition hz-link"
>
View all 41+ models
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="hz-row hz-ai-center hz-gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="hz-p-2 hz-r-md hz-transition hz-fg-muted hz-link"
>
{isExpanded ? (
<Minimize2 className="hz-sq-2" />
) : (
<Maximize2 className="hz-sq-2" />
)}
</button>
<button
onClick={() => {
setIsOpen(false);
abortRef.current?.abort();
}}
className="hz-p-2 hz-r-md hz-transition hz-fg-muted hz-link"
>
<X className="hz-sq-2" />
</button>
</div>
</div>
{/* Demo mode banner */}
{isDemoMode && (
<div className="hz-px-4 hz-py-2 hz-bg-quiet hz-border-b hz-align-center">
<span className="hz-t-xs hz-fg-muted">
Demo mode -- AI responses coming soon
</span>
</div>
)}
{/* Messages */}
<div className="hz-grow hz-scroll-y hz-p-4 hz-stack-4 hz-rel">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"hz-row",
message.role === "user" ? "hz-jc-end" : "hz-jc-start"
)}
>
<div
className={cn(
"hz-mw-full hz-px-3 hz-py-2 hz-r-xl hz-t-sm",
message.role === "user"
? "hz-bg-inverse hz-fg-inverse"
: "hz-bg-quiet hz-fg-soft"
)}
>
{message.role === "assistant" ? (
<div className="hz-stack-0">
{renderMarkdown(message.content)}
{message.isStreaming && (
<span className="hz-bw-1 hz-bh-2 hz-ml-1 hz-bg-raised" />
)}
</div>
) : (
message.content
)}
</div>
</div>
))}
{/* Loading indicator (only when no streaming message is visible) */}
{isLoading &&
!messages.some((m) => m.isStreaming) && (
<div className="hz-row hz-jc-start">
<div className="hz-px-4 hz-py-2 hz-r-xl hz-bg-quiet">
<div className="hz-row hz-gap-1">
<span
className="hz-sq-1 hz-r-full hz-bg-raised"
style={{ animationDelay: "0ms" }}
/>
<span
className="hz-sq-1 hz-r-full hz-bg-raised"
style={{ animationDelay: "150ms" }}
/>
<span
className="hz-sq-1 hz-r-full hz-bg-raised"
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="hz-abs hz-inset hz-z-raised hz-row hz-ai-center hz-jc-center hz-bg-surface hz-glass"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="hz-card hz-align-center hz-mw-full"
>
<div className="hz-sq-7 hz-mx-auto hz-mb-4 hz-r-full hz-bg-quiet hz-bordered hz-row hz-ai-center hz-jc-center">
<Lock className="hz-sq-3 hz-fg-muted" />
</div>
<h3 className="hz-t-base hz-w-semibold hz-fg hz-mb-2">
Keep going on Hanzo
</h3>
<p className="hz-t-xs hz-fg-muted hz-mb-4 hz-leading-relaxed">
You have used your free message. Sign in on Hanzo for
unlimited chat and every Zen model.
</p>
<a
href={site.try.href}
className="hz-w-full hz-px-4 hz-py-2 hz-r-lg hz-bg-inverse hz-fg-inverse hz-t-sm hz-w-medium hz-transition hz-hoverable"
>
{site.try.label}
</a>
<button
onClick={() => setShowLoginGate(false)}
className="hz-mt-3 hz-t-xs hz-fg-muted hz-transition hz-link"
>
Dismiss
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Preset buttons */}
{messages.length <= 1 && !showLoginGate && (
<div className="hz-px-4 hz-pb-4">
<div className="hz-row hz-wrap hz-gap-2">
{chatPresets.map((preset) => {
const Icon = preset.icon;
return (
<button
key={preset.label}
onClick={() => handlePreset(preset)}
className="hz-btn hz-btn-primary hz-gap-2 hz-t-xs hz-transition hz-fg-muted"
>
<Icon className="hz-sq-1" />
{preset.label}
</button>
);
})}
</div>
</div>
)}
{/* Input */}
<div className="hz-p-3 hz-border-t">
<div className="hz-rel">
<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(
"hz-w-full hz-r-full hz-px-4 hz-py-2 hz-px-6 hz-t-sm hz-transition hz-bordered",
"hz-bg-quiet hz-fg",
"",
showLoginGate && "hz-dim-more"
)}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading || showLoginGate}
className={cn(
"hz-center-y hz-sq-5 hz-abs hz-r-full hz-row hz-ai-center hz-jc-center hz-transition",
input.trim() ? "hz-bg-inverse" : "hz-bg-none"
)}
>
<Send
className={cn(
"hz-sq-2",
input.trim() ? "hz-fg-inverse" : "hz-fg-muted"
)}
/>
</button>
</div>
<div className="hz-mt-2 hz-align-center">
<span className="hz-t-xs hz-fg-faint">
{`${Math.max(0, FREE_MESSAGE_LIMIT - getChatCount())} free message${FREE_MESSAGE_LIMIT - getChatCount() !== 1 ? "s" : ""} remaining`}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}