Files
zeekayandHanzo Dev da98c0de75 feat(console): one icon set — every glyph comes from @luxfi/ui/icons
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]>
2026-07-27 14:57:55 -07:00

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