mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
- web/lib/brand.ts: zoo iam.url + iam.domain → zoolabs.id (zoo.id is NXDOMAIN)
- web/components/navbar.tsx: brand-aware `npm i {sdk}` snippet
- web/components/ai-chat.tsx: brand-namespaced localStorage keys
- web/components/lux/region-data.ts: brand-namespaced localStorage keys
- web/app/products/wallets/page.tsx: brand-aware SDK class + package in sample
- web/app/terms/page.tsx: BOOTNODE → {brand.name.toUpperCase()} in legal text
- web/lib/sdk-package.ts: shared brand→pkg/client map (single source of truth)
305 lines
9.9 KiB
TypeScript
305 lines
9.9 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react"
|
|
import {
|
|
Bot,
|
|
Send,
|
|
X,
|
|
Loader2,
|
|
Maximize2,
|
|
Minimize2,
|
|
Sparkles,
|
|
Server,
|
|
Activity,
|
|
Zap,
|
|
RotateCw,
|
|
} from "lucide-react"
|
|
import { useBrand } from "@/components/brand-logo"
|
|
import { getBrandKey_ } from "@/lib/brand"
|
|
|
|
interface ChatMessage {
|
|
role: "user" | "assistant"
|
|
content: string
|
|
timestamp: Date
|
|
}
|
|
|
|
const QUICK_ACTIONS = [
|
|
{ label: "Fleet status", prompt: "Show me the current fleet status across all networks", icon: Server },
|
|
{ label: "Health check", prompt: "Run a health check on all services", icon: Activity },
|
|
{ label: "Recent alerts", prompt: "Are there any active alerts?", icon: Zap },
|
|
{ label: "Scale nodes", prompt: "How do I scale my validator nodes?", icon: RotateCw },
|
|
]
|
|
|
|
export function AiChat({
|
|
isOpen,
|
|
onClose,
|
|
initialPrompt,
|
|
}: {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
initialPrompt?: string
|
|
}) {
|
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
const [input, setInput] = useState("")
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const brand = useBrand()
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
}
|
|
|
|
useEffect(() => {
|
|
scrollToBottom()
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
if (isOpen && inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (initialPrompt && isOpen && messages.length === 0) {
|
|
sendMessage(initialPrompt)
|
|
}
|
|
}, [initialPrompt, isOpen])
|
|
|
|
const sendMessage = useCallback(async (content: string) => {
|
|
if (!content.trim() || isLoading) return
|
|
|
|
const userMsg: ChatMessage = { role: "user", content: content.trim(), timestamp: new Date() }
|
|
setMessages((prev) => [...prev, userMsg])
|
|
setInput("")
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
const allMessages = [...messages, userMsg].map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
}))
|
|
|
|
const tokenKey = `${getBrandKey_()}_token`
|
|
const apiKeyKey = `${getBrandKey_()}_api_key`
|
|
const res = await fetch("/api/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(typeof localStorage !== "undefined" && localStorage.getItem(tokenKey)
|
|
? { Authorization: `Bearer ${localStorage.getItem(tokenKey)}` }
|
|
: {}),
|
|
...(typeof localStorage !== "undefined" && localStorage.getItem(apiKeyKey)
|
|
? { "X-API-Key": localStorage.getItem(apiKeyKey)! }
|
|
: {}),
|
|
},
|
|
body: JSON.stringify({
|
|
messages: allMessages,
|
|
stream: true,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Chat API error: ${res.status}`)
|
|
}
|
|
|
|
// Stream the response
|
|
const reader = res.body?.getReader()
|
|
const decoder = new TextDecoder()
|
|
let assistantContent = ""
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
role: "assistant",
|
|
content: "",
|
|
timestamp: new Date(),
|
|
}
|
|
setMessages((prev) => [...prev, assistantMsg])
|
|
|
|
if (reader) {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
const lines = chunk.split("\n")
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("data: ")) {
|
|
const data = line.slice(6)
|
|
if (data === "[DONE]") continue
|
|
|
|
try {
|
|
const parsed = JSON.parse(data)
|
|
const delta = parsed.choices?.[0]?.delta?.content
|
|
if (delta) {
|
|
assistantContent += delta
|
|
setMessages((prev) => {
|
|
const updated = [...prev]
|
|
const last = updated[updated.length - 1]
|
|
if (last?.role === "assistant") {
|
|
updated[updated.length - 1] = { ...last, content: assistantContent }
|
|
}
|
|
return updated
|
|
})
|
|
}
|
|
} catch {
|
|
// Skip non-JSON lines
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: "Sorry, I'm having trouble connecting to the AI service. Please try again.",
|
|
timestamp: new Date(),
|
|
},
|
|
])
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [messages, isLoading])
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
sendMessage(input)
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div
|
|
className={`fixed z-50 bg-background border border-border rounded-xl shadow-2xl flex flex-col transition-all duration-200 ${
|
|
isExpanded
|
|
? "inset-4"
|
|
: "bottom-4 right-4 w-[420px] h-[600px] max-h-[80vh]"
|
|
}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{brand.name} AI</h3>
|
|
<p className="text-[10px] text-muted-foreground">Infrastructure assistant</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="p-1.5 rounded-md hover:bg-accent transition-colors"
|
|
>
|
|
{isExpanded ? (
|
|
<Minimize2 className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-md hover:bg-accent transition-colors"
|
|
>
|
|
<X className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
|
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
|
<Bot className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<h4 className="text-sm font-semibold mb-1">How can I help?</h4>
|
|
<p className="text-xs text-muted-foreground mb-6">
|
|
Ask about your infrastructure, scale services, check fleet status, or search logs.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2 w-full">
|
|
{QUICK_ACTIONS.map((action) => (
|
|
<button
|
|
key={action.label}
|
|
onClick={() => sendMessage(action.prompt)}
|
|
className="flex items-center gap-2 p-2.5 rounded-lg border border-border hover:bg-accent text-left transition-colors"
|
|
>
|
|
<action.icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-xs">{action.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
|
>
|
|
{msg.role === "assistant" && (
|
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
<Sparkles className="h-3 w-3 text-primary" />
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted"
|
|
}`}
|
|
>
|
|
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isLoading && messages[messages.length - 1]?.role === "user" && (
|
|
<div className="flex gap-3">
|
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
|
<Sparkles className="h-3 w-3 text-primary" />
|
|
</div>
|
|
<div className="bg-muted rounded-lg px-3 py-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-3 border-t border-border">
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask about your infrastructure..."
|
|
rows={1}
|
|
className="flex-1 resize-none bg-muted rounded-lg px-3 py-2.5 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-primary/50 max-h-[120px]"
|
|
style={{ minHeight: "40px" }}
|
|
/>
|
|
<button
|
|
onClick={() => sendMessage(input)}
|
|
disabled={!input.trim() || isLoading}
|
|
className="p-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1.5 text-center">
|
|
AI can make mistakes. Verify critical infrastructure changes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|