import { useEffect, useRef, useState, useMemo } from "react"; import { Code, Download, FormInput, RotateCcw, Save, Search, Upload, X, ChevronRight, Settings2, FileText, Settings, Bot, Monitor, Palette, Users, Brain, Package, Lock, Globe, Mic, Volume2, Ear, ClipboardList, MessageCircle, Wrench, FileQuestion, } from "lucide-react"; import { api } from "@/lib/api"; import { getNestedValue, setNestedValue } from "@/lib/nested"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { AutoField } from "@/components/AutoField"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const CATEGORY_ICONS: Record> = { general: Settings, agent: Bot, terminal: Monitor, display: Palette, delegation: Users, memory: Brain, compression: Package, security: Lock, browser: Globe, voice: Mic, tts: Volume2, stt: Ear, logging: ClipboardList, discord: MessageCircle, auxiliary: Wrench, }; function CategoryIcon({ category, className }: { category: string; className?: string }) { const Icon = CATEGORY_ICONS[category] ?? FileQuestion; return ; } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export default function ConfigPage() { const [config, setConfig] = useState | null>(null); const [schema, setSchema] = useState> | null>(null); const [categoryOrder, setCategoryOrder] = useState([]); const [defaults, setDefaults] = useState | null>(null); const [saving, setSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [yamlMode, setYamlMode] = useState(false); const [yamlText, setYamlText] = useState(""); const [yamlLoading, setYamlLoading] = useState(false); const [yamlSaving, setYamlSaving] = useState(false); const [activeCategory, setActiveCategory] = useState(""); const { toast, showToast } = useToast(); const fileInputRef = useRef(null); const { t } = useI18n(); function prettyCategoryName(cat: string): string { const key = cat as keyof typeof t.config.categories; if (t.config.categories[key]) return t.config.categories[key]; return cat.charAt(0).toUpperCase() + cat.slice(1); } useEffect(() => { api.getConfig().then(setConfig).catch(() => {}); api .getSchema() .then((resp) => { setSchema(resp.fields as Record>); setCategoryOrder(resp.category_order ?? []); }) .catch(() => {}); api.getDefaults().then(setDefaults).catch(() => {}); }, []); // Set active category when categories load useEffect(() => { if (categoryOrder.length > 0 && !activeCategory) { setActiveCategory(categoryOrder[0]); } }, [categoryOrder, activeCategory]); // Load YAML when switching to YAML mode useEffect(() => { if (yamlMode) { setYamlLoading(true); api .getConfigRaw() .then((resp) => setYamlText(resp.yaml)) .catch(() => showToast(t.config.failedToLoadRaw, "error")) .finally(() => setYamlLoading(false)); } }, [yamlMode]); /* ---- Categories ---- */ const categories = useMemo(() => { if (!schema) return []; const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))]; const ordered = categoryOrder.filter((c) => allCats.includes(c)); const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort(); return [...ordered, ...extra]; }, [schema, categoryOrder]); /* ---- Category field counts ---- */ const categoryCounts = useMemo(() => { if (!schema) return {}; const counts: Record = {}; for (const s of Object.values(schema)) { const cat = String(s.category ?? "general"); counts[cat] = (counts[cat] || 0) + 1; } return counts; }, [schema]); /* ---- Search ---- */ const isSearching = searchQuery.trim().length > 0; const lowerSearch = searchQuery.toLowerCase(); const searchMatchedFields = useMemo(() => { if (!isSearching || !schema) return []; return Object.entries(schema).filter(([key, s]) => { const label = key.split(".").pop() ?? key; const humanLabel = label.replace(/_/g, " "); return ( key.toLowerCase().includes(lowerSearch) || humanLabel.toLowerCase().includes(lowerSearch) || String(s.category ?? "").toLowerCase().includes(lowerSearch) || String(s.description ?? "").toLowerCase().includes(lowerSearch) ); }); }, [isSearching, lowerSearch, schema]); /* ---- Active tab fields ---- */ const activeFields = useMemo(() => { if (!schema || isSearching) return []; return Object.entries(schema).filter( ([, s]) => String(s.category ?? "general") === activeCategory ); }, [schema, activeCategory, isSearching]); /* ---- Handlers ---- */ const handleSave = async () => { if (!config) return; setSaving(true); try { await api.saveConfig(config); showToast(t.config.configSaved, "success"); } catch (e) { showToast(`${t.config.failedToSave}: ${e}`, "error"); } finally { setSaving(false); } }; const handleYamlSave = async () => { setYamlSaving(true); try { await api.saveConfigRaw(yamlText); showToast(t.config.yamlConfigSaved, "success"); api.getConfig().then(setConfig).catch(() => {}); } catch (e) { showToast(`${t.config.failedToSaveYaml}: ${e}`, "error"); } finally { setYamlSaving(false); } }; const handleReset = () => { if (defaults) setConfig(structuredClone(defaults)); }; const handleExport = () => { if (!config) return; const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "hermes-config.json"; a.click(); URL.revokeObjectURL(url); }; const handleImport = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const imported = JSON.parse(reader.result as string); setConfig(imported); showToast(t.config.configImported, "success"); } catch { showToast(t.config.invalidJson, "error"); } }; reader.readAsText(file); }; /* ---- Loading ---- */ if (!config || !schema) { return (
); } /* ---- Render field list (shared between search & normal) ---- */ const renderFields = (fields: [string, Record][], showCategory = false) => { let lastSection = ""; let lastCat = ""; return fields.map(([key, s]) => { const parts = key.split("."); const section = parts.length > 1 ? parts[0] : ""; const cat = String(s.category ?? "general"); const showCatBadge = showCategory && cat !== lastCat; const showSection = !showCategory && section && section !== lastSection && section !== activeCategory; lastSection = section; lastCat = cat; return (
{showCatBadge && (
{prettyCategoryName(cat)}
)} {showSection && (
{section.replace(/_/g, " ")}
)}
setConfig(setNestedValue(config, key, v))} />
); }); }; return (
{/* ═══════════════ Header Bar ═══════════════ */}
{t.config.configPath}
{yamlMode ? ( ) : ( )}
{/* ═══════════════ YAML Mode ═══════════════ */} {yamlMode ? ( {t.config.rawYaml} {yamlLoading ? (
) : (