mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
The magnifying glass was drawn over the leading "S" of the placeholder. The cause is not the search bar: @luxfi/ui's InputGroup measures its adornments correctly and then throws the measurement away, because the clone is guarded on `displayName === 'FieldInput'` — a component that does not exist in the package — and the props it would have set (`ps`/`pe`) are Chakra style props that the Tailwind/gui Input spreads onto the DOM and ignores. Both halves are leftovers of the Chakra→Tailwind port, so EVERY InputGroup in the app is unpadded; the header is just the one you look at every day. Patched at the primitive (patches/@[email protected]) rather than hand-tuning padding at one call site: the offset is the measured width of the actual adornment, so it stays right when the adornment changes, and FilterInput / revoke's SearchInput / the showcase are fixed by the same change. Measured at 1440x900: the icon overlapped the text by 24px and the kbd chip by 52px; both are now 0. The measurement also moves to a layout effect and stops blanking value and placeholder on first render. That blanking was inert while the guard swallowed the clone; with the clone live it flips the field from controlled to uncontrolled on mount, which React warns about and which can eat a keystroke. ClearButton spread `className` over its own visibility classes, so a caller passing one clobbered `invisible` — the header showed a clear button on an empty field. Merged instead, at the component. Placeholder is now "Search or ask anything", and Cmd/Ctrl-K opens an assistant. Nothing in this repo, @luxfi/ui or @hanzo/gui ships a command palette or chat (no cmdk, no palette, one dialog primitive), so this is the smallest thing that works: the existing Dialog, one global listener, and a server-side proxy that holds the credential. The key is deliberately not NEXT_PUBLIC_, so a deployment without one gets a dialog that says exactly that instead of failing silently. Off unless NEXT_PUBLIC_AI_ASSISTANT_ENABLED is set — Ctrl+K is the browser's address-bar shortcut and claiming it to show an error would be worse than not claiming it. Validators read 5 on a network running 20. useCurrentValidators asks ONE P-Chain, the one this deployment points at, but Lux, Hanzo, Zoo and Pars are each sovereign L1s with their own P-Chain and their own validator set — measured 2026-08-06, five each, with four disjoint NodeID sets. The registry gains the node origin per chain; absence of it is the signal that a chain has no publicly reachable node (SPC and Osage are in-cluster only), and those are reported `unavailable` rather than counted as zero. Stake is deliberately NOT summed: each chain bonds its own currency. The proxy resolves `?chain=` through the registry and never from the request, so it cannot be pointed at an arbitrary origin. The new envs are registered with the envs-validator and documented in ENVS.md — the schema rejects unknown NEXT_PUBLIC_* variables and runs at container start, so an undeclared one stops the pod from booting. Co-authored-by: Hanzo Dev <[email protected]>
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
// Server-side proxy for the ⌘K assistant.
|
|
//
|
|
// The credential lives here and never reaches a bundle, which is the only
|
|
// reason this hop exists — same shape as pages/api/pchain.ts. Hanzo AI
|
|
// (api.hanzo.ai) is the one AI stack; the endpoint is OpenAI-compatible.
|
|
//
|
|
// This route is reachable by anyone who can load the explorer, so it is bounded
|
|
// rather than trusted: the request shape, the number of messages, their total
|
|
// size and the reply length are all capped before our key is spent.
|
|
|
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
|
|
const TIMEOUT_MS = 30_000;
|
|
const MAX_MESSAGES = 20;
|
|
const MAX_CHARS = 8_000;
|
|
const MAX_TOKENS = 800;
|
|
|
|
const API_URL = process.env.AI_API_URL || 'https://api.hanzo.ai/v1/chat/completions';
|
|
const MODEL = process.env.NEXT_PUBLIC_AI_ASSISTANT_MODEL || 'best';
|
|
|
|
const SYSTEM_PROMPT =
|
|
'You are the assistant built into the Lux Network block explorer. Answer questions about ' +
|
|
'addresses, transactions, blocks, tokens, validators and the Lux chains. Be brief. When you ' +
|
|
'do not know something, say so — never invent an address, hash, balance or block number.';
|
|
|
|
interface Message {
|
|
readonly role: 'user' | 'assistant';
|
|
readonly content: string;
|
|
}
|
|
|
|
function parseMessages(body: unknown): ReadonlyArray<Message> | undefined {
|
|
const messages = (body as { messages?: unknown })?.messages;
|
|
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) {
|
|
return undefined;
|
|
}
|
|
const parsed: Array<Message> = [];
|
|
let chars = 0;
|
|
for (const m of messages) {
|
|
const role = (m as Message)?.role;
|
|
const content = (m as Message)?.content;
|
|
if ((role !== 'user' && role !== 'assistant') || typeof content !== 'string' || !content) {
|
|
return undefined;
|
|
}
|
|
chars += content.length;
|
|
if (chars > MAX_CHARS) {
|
|
return undefined;
|
|
}
|
|
parsed.push({ role, content });
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
|
|
if (req.method !== 'POST') {
|
|
res.status(405).json({ error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
const apiKey = process.env.AI_API_KEY;
|
|
if (!apiKey) {
|
|
// Configured on but not credentialled. Say which, so the dialog can too.
|
|
res.status(501).json({ error: 'The assistant is not configured on this deployment (AI_API_KEY is unset).' });
|
|
return;
|
|
}
|
|
|
|
const messages = parseMessages(req.body);
|
|
if (!messages) {
|
|
res.status(400).json({ error: 'Expected { messages: [{ role, content }] } within the size limits' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${ apiKey }`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
max_tokens: MAX_TOKENS,
|
|
messages: [ { role: 'system', content: SYSTEM_PROMPT }, ...messages ],
|
|
}),
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
|
|
// Same guard as the P-chain proxy: a gateway error answers HTML, and
|
|
// JSON.parse on that is an opaque crash rather than a usable message.
|
|
const raw = await response.text();
|
|
let data: { choices?: Array<{ message?: { content?: string } }> };
|
|
try {
|
|
data = JSON.parse(raw) as { choices?: Array<{ message?: { content?: string } }> };
|
|
} catch {
|
|
res.status(502).json({ error: `The assistant upstream returned non-JSON (HTTP ${ response.status })` });
|
|
return;
|
|
}
|
|
|
|
const content = data.choices?.[0]?.message?.content;
|
|
if (!response.ok || !content) {
|
|
res.status(502).json({ error: `The assistant upstream returned HTTP ${ response.status } with no answer` });
|
|
return;
|
|
}
|
|
|
|
res.status(200).json({ content });
|
|
} catch (error) {
|
|
res.status(502).json({
|
|
error: error instanceof Error ? error.message : 'The assistant request failed',
|
|
});
|
|
}
|
|
};
|
|
|
|
export default handler;
|