Files
bootnode/web/components/ai-chat.tsx
zeekay 022a134eee fix(brand): remove hardcoded Bootnode leaks (terms, navbar, ai-chat, region-data, wallets sdk sample) + zoo IAM → zoolabs.id
- 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)
2026-04-30 00:24:49 -07:00

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>
)
}