import { useCallback, useEffect, useMemo, useState } from "react"; import { Eye, EyeOff, ExternalLink, KeyRound, MessageSquare, Pencil, Save, Settings, Trash2, X, Zap, ChevronDown, ChevronRight, } from "lucide-react"; import { api } from "@/lib/api"; import type { EnvVarInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { Toast } from "@/components/Toast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { useToast } from "@/hooks/useToast"; import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; /* ------------------------------------------------------------------ */ /* Provider grouping */ /* ------------------------------------------------------------------ */ /** Map env-var key prefixes to a human-friendly provider name + ordering. */ const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [ // Nous Portal first { prefix: "NOUS_", name: "Nous Portal", priority: 0 }, // Then alphabetical by display name { prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 }, { prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 }, { prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 }, { prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 }, { prefix: "GOOGLE_", name: "Gemini", priority: 4 }, { prefix: "GEMINI_", name: "Gemini", priority: 4 }, { prefix: "GLM_", name: "GLM / Z.AI", priority: 5 }, { prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 }, { prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 }, { prefix: "HF_", name: "Hugging Face", priority: 6 }, { prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 }, { prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 }, { prefix: "MINIMAX_", name: "MiniMax", priority: 8 }, { prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 }, { prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 }, { prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 }, { prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 }, ]; function getProviderGroup(key: string): string { for (const g of PROVIDER_GROUPS) { if (key.startsWith(g.prefix)) return g.name; } return "Other"; } function getProviderPriority(groupName: string): number { const entry = PROVIDER_GROUPS.find((g) => g.name === groupName); return entry?.priority ?? 99; } interface ProviderGroup { name: string; priority: number; entries: [string, EnvVarInfo][]; hasAnySet: boolean; } const CATEGORY_META_ICONS: Record = { provider: Zap, tool: KeyRound, messaging: MessageSquare, setting: Settings, }; /* ------------------------------------------------------------------ */ /* EnvVarRow — single key edit row */ /* ------------------------------------------------------------------ */ function EnvVarRow({ varKey, info, edits, setEdits, revealed, saving, onSave, onClear, onReveal, onCancelEdit, clearDialogOpen = false, compact = false, }: { varKey: string; info: EnvVarInfo; edits: Record; setEdits: React.Dispatch>>; revealed: Record; saving: string | null; onSave: (key: string) => void; onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; clearDialogOpen?: boolean; compact?: boolean; }) { const { t } = useI18n(); const isEditing = edits[varKey] !== undefined; const isRevealed = !!revealed[varKey]; const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---"); // Compact inline row for unset, non-editing keys (used inside provider groups) if (compact && !info.is_set && !isEditing) { return (
{varKey} {info.description}
{info.url && ( {t.env.getKey} )}
); } // Non-compact unset row if (!info.is_set && !isEditing) { return (
{info.description}
{info.url && ( {t.env.getKey} )}
); } // Full expanded row for set keys or keys being edited return (
{info.is_set ? t.common.set : t.env.notSet}
{info.url && ( {t.env.getKey} )}

{info.description}

{info.tools.length > 0 && (
{info.tools.map((tool) => ( {tool} ))}
)} {!isEditing && (
{info.is_set ? displayValue : "---"}
{info.is_set && ( )} {info.is_set && ( )}
)} {isEditing && (
setEdits((prev) => ({ ...prev, [varKey]: e.target.value })) } placeholder={ info.is_set ? t.env.replaceCurrentValue.replace( "{preview}", info.redacted_value ?? "---", ) : t.env.enterValue } className="flex-1 font-mono-ui text-xs" />
)}
); } /* ------------------------------------------------------------------ */ /* ProviderGroupCard — groups API key + base URL per provider */ /* ------------------------------------------------------------------ */ function ProviderGroupCard({ group, edits, setEdits, revealed, saving, onSave, onClear, onReveal, onCancelEdit, clearDialogOpen = false, }: { group: ProviderGroup; edits: Record; setEdits: React.Dispatch>>; revealed: Record; saving: string | null; onSave: (key: string) => void; onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; clearDialogOpen?: boolean; }) { const [expanded, setExpanded] = useState(false); const { t } = useI18n(); // Separate API keys from base URLs and other settings const apiKeys = group.entries.filter( ([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"), ); const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL")); const other = group.entries.filter( ([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"), ); const hasAnyConfigured = group.entries.some(([, info]) => info.is_set); const configuredCount = group.entries.filter( ([, info]) => info.is_set, ).length; // Get a representative URL for "Get key" link const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null; return (
{/* Header — always visible */} setExpanded(!expanded)} aria-expanded={expanded} className="justify-between gap-3 px-4 py-3 hover:bg-primary/5" >
{expanded ? ( ) : ( )} {group.name === "Other" ? t.common.other : group.name} {hasAnyConfigured && ( {configuredCount} {t.common.set.toLowerCase()} )}
{keyUrl && ( e.stopPropagation()} > {t.env.getKey} )} {t.env.keysCount .replace("{count}", String(group.entries.length)) .replace("{s}", group.entries.length !== 1 ? "s" : "")}
{expanded && (
{apiKeys.map(([key, info]) => ( ))} {baseUrls.map(([key, info]) => ( ))} {other.map(([key, info]) => ( ))}
)}
); } /* ------------------------------------------------------------------ */ /* Main page */ /* ------------------------------------------------------------------ */ export default function EnvPage() { const [vars, setVars] = useState | null>(null); const [edits, setEdits] = useState>({}); const [revealed, setRevealed] = useState>({}); const [saving, setSaving] = useState(null); const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default const { toast, showToast } = useToast(); const { t } = useI18n(); useEffect(() => { api .getEnvVars() .then(setVars) .catch(() => {}); }, []); const handleSave = async (key: string) => { const value = edits[key]; if (!value) return; setSaving(key); try { await api.setEnvVar(key, value); setVars((prev) => prev ? { ...prev, [key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4), }, } : prev, ); setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); showToast(`${key} ${t.common.save.toLowerCase()}d`, "success"); } catch (e) { showToast(`${t.config.failedToSave} ${key}: ${e}`, "error"); } finally { setSaving(null); } }; const keyClear = useConfirmDelete({ onDelete: useCallback( async (key: string) => { setSaving(key); try { await api.deleteEnvVar(key); setVars((prev) => prev ? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null }, } : prev, ); setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); showToast(`${key} ${t.common.removed}`, "success"); } catch (e) { showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error"); throw e; } finally { setSaving(null); } }, [showToast, t.common.removed, t.common.failedToRemove], ), }); const handleReveal = async (key: string) => { if (revealed[key]) { setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); return; } try { const resp = await api.revealEnvVar(key); setRevealed((prev) => ({ ...prev, [key]: resp.value })); } catch { showToast(`${t.common.failedToReveal} ${key}`, "error"); } }; const cancelEdit = (key: string) => { setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); }; /* ---- Build provider groups ---- */ const { providerGroups, nonProviderGrouped } = useMemo(() => { if (!vars) return { providerGroups: [], nonProviderGrouped: [] }; const providerEntries = Object.entries(vars).filter( ([, info]) => info.category === "provider" && (showAdvanced || !info.advanced), ); // Group by provider const groupMap = new Map(); for (const entry of providerEntries) { const groupName = getProviderGroup(entry[0]); if (!groupMap.has(groupName)) groupMap.set(groupName, []); groupMap.get(groupName)!.push(entry); } const groups: ProviderGroup[] = Array.from(groupMap.entries()) .map(([name, entries]) => ({ name, priority: getProviderPriority(name), entries, hasAnySet: entries.some(([, info]) => info.is_set), })) .sort((a, b) => a.priority - b.priority); // Non-provider categories — use translated labels const CATEGORY_META_LABELS: Record = { tool: t.app.nav.keys, messaging: t.common.messaging, setting: t.app.nav.config, }; const otherCategories = ["tool", "messaging", "setting"]; const nonProvider = otherCategories.map((cat) => { const entries = Object.entries(vars).filter( ([, info]) => info.category === cat && (showAdvanced || !info.advanced), ); const setEntries = entries.filter(([, info]) => info.is_set); const unsetEntries = entries.filter(([, info]) => !info.is_set); return { label: CATEGORY_META_LABELS[cat] ?? cat, icon: CATEGORY_META_ICONS[cat] ?? KeyRound, category: cat, setEntries, unsetEntries, totalEntries: entries.length, }; }); return { providerGroups: groups, nonProviderGrouped: nonProvider }; }, [vars, showAdvanced, t]); if (!vars) { return (
); } const totalProviders = providerGroups.length; const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length; const pendingClearKey = keyClear.pendingId; const pendingKeyDescription = pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined; return (

{t.env.description} ~/.hermes/.env

{t.env.changesNote}

showToast(msg, "error")} onSuccess={(msg) => showToast(msg, "success")} />
{t.env.llmProviders}
{t.env.providersConfigured .replace("{configured}", String(configuredProviders)) .replace("{total}", String(totalProviders))}
{providerGroups.map((group) => ( ))}
{nonProviderGrouped.map( ({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category, }) => { if (totalEntries === 0) return null; return (
{label}
{setEntries.length} {t.common.of} {totalEntries}{" "} {t.common.configured}
{setEntries.map(([key, info]) => ( ))} {unsetEntries.length > 0 && ( )}
); }, )}
); } /* ------------------------------------------------------------------ */ /* CollapsibleUnset — for non-provider categories */ /* ------------------------------------------------------------------ */ function CollapsibleUnset({ category: _category, unsetEntries, edits, setEdits, revealed, saving, onSave, onClear, onReveal, onCancelEdit, clearDialogOpen = false, }: { category: string; unsetEntries: [string, EnvVarInfo][]; edits: Record; setEdits: React.Dispatch>>; revealed: Record; saving: string | null; onSave: (key: string) => void; onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; clearDialogOpen?: boolean; }) { const [collapsed, setCollapsed] = useState(true); const { t } = useI18n(); return ( <> {!collapsed && unsetEntries.map(([key, info]) => ( ))} ); }