mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
74 files imported `lucide-react` directly, pinned ^1.26.0, while @luxfi/ui — which this console renders alongside — resolves 1.27. Two Lucide copies from two resolutions in one bundle is how a set stops being one set. Two more glyphs were not from any icon library at all: the node list drew its network in/out arrows as the literal characters "↓" and "↑", and the chain onboarding selector drew its tick as "✓". Those are font glyphs — they take the text metrics, not the icon box, so they sit on a different baseline and scale with the font, not `size-*`. One source now: `@luxfi/ui/icons`, Lucide as @hanzo/gui standardises on it and @luxfi/ui themes it. `lucide-react` is out of package.json, so per-surface drift is no longer expressible; @luxfi/ui comes in at 7.4.3, which is where `./icons` lives. The three text glyphs are ArrowDown / ArrowUp / Check. All 121 distinct icon names this console uses were checked against the 6014 symbols @luxfi/ui/icons exports before the rewrite — zero missing, with a negative control. `next build` is clean: 78 routes, Next 16.2.11 + Turbopack. The d3 chart roots (node-globe, node-map, globe-view, donut-chart, fleet-map) keep their <svg>: those are plots, not glyphs. The command menu's "↑↓" stays — it is a keyboard legend inside <kbd>, and it means the keys. Co-authored-by: Hanzo Dev <[email protected]>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
"use client"
|
|
import { apiBase } from "@/lib/api"
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react"
|
|
import {
|
|
Bot,
|
|
Send,
|
|
X,
|
|
Loader2,
|
|
Maximize2,
|
|
Minimize2,
|
|
Sparkles,
|
|
Server,
|
|
Activity,
|
|
Zap,
|
|
RotateCw,
|
|
} from "@luxfi/ui/icons"
|
|
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(`${apiBase()}/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>
|
|
)
|
|
}
|