ConfigPage.tsx
1 import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react"; 2 import { 3 Code, 4 Download, 5 FormInput, 6 RotateCcw, 7 Save, 8 Search, 9 Upload, 10 X, 11 Settings2, 12 FileText, 13 Settings, 14 Bot, 15 Monitor, 16 Palette, 17 Users, 18 Brain, 19 Package, 20 Lock, 21 Globe, 22 Mic, 23 Volume2, 24 Ear, 25 ClipboardList, 26 MessageCircle, 27 Wrench, 28 FileQuestion, 29 Filter, 30 Cloud, 31 Sparkles, 32 LayoutDashboard, 33 BookOpen, 34 Route, 35 History, 36 Shield, 37 FileOutput, 38 RefreshCw, 39 } from "lucide-react"; 40 import { api } from "@/lib/api"; 41 import { getNestedValue, setNestedValue } from "@/lib/nested"; 42 import { useToast } from "@/hooks/useToast"; 43 import { Toast } from "@/components/Toast"; 44 import { AutoField } from "@/components/AutoField"; 45 import { Button } from "@nous-research/ui/ui/components/button"; 46 import { ListItem } from "@nous-research/ui/ui/components/list-item"; 47 import { Spinner } from "@nous-research/ui/ui/components/spinner"; 48 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 49 import { Input } from "@/components/ui/input"; 50 import { Badge } from "@nous-research/ui/ui/components/badge"; 51 import { useI18n } from "@/i18n"; 52 import { usePageHeader } from "@/contexts/usePageHeader"; 53 import { PluginSlot } from "@/plugins"; 54 55 /* ------------------------------------------------------------------ */ 56 /* Helpers */ 57 /* ------------------------------------------------------------------ */ 58 59 const CATEGORY_ICONS: Record< 60 string, 61 React.ComponentType<{ className?: string }> 62 > = { 63 general: Settings, 64 agent: Bot, 65 terminal: Monitor, 66 display: Palette, 67 delegation: Users, 68 memory: Brain, 69 compression: Package, 70 security: Lock, 71 browser: Globe, 72 voice: Mic, 73 tts: Volume2, 74 stt: Ear, 75 logging: ClipboardList, 76 discord: MessageCircle, 77 auxiliary: Wrench, 78 bedrock: Cloud, 79 curator: Sparkles, 80 kanban: LayoutDashboard, 81 model_catalog: BookOpen, 82 openrouter: Route, 83 sessions: History, 84 tool_loop_guardrails: Shield, 85 tool_output: FileOutput, 86 updates: RefreshCw, 87 }; 88 89 function CategoryIcon({ 90 category, 91 className, 92 }: { 93 category: string; 94 className?: string; 95 }) { 96 const Icon = CATEGORY_ICONS[category] ?? FileQuestion; 97 return <Icon className={className ?? "h-4 w-4"} />; 98 } 99 100 /* ------------------------------------------------------------------ */ 101 /* Component */ 102 /* ------------------------------------------------------------------ */ 103 104 export default function ConfigPage() { 105 const [config, setConfig] = useState<Record<string, unknown> | null>(null); 106 const [schema, setSchema] = useState<Record< 107 string, 108 Record<string, unknown> 109 > | null>(null); 110 const [categoryOrder, setCategoryOrder] = useState<string[]>([]); 111 const [defaults, setDefaults] = useState<Record<string, unknown> | null>( 112 null, 113 ); 114 const [saving, setSaving] = useState(false); 115 const [searchQuery, setSearchQuery] = useState(""); 116 const [yamlMode, setYamlMode] = useState(false); 117 const [yamlText, setYamlText] = useState(""); 118 const [yamlLoading, setYamlLoading] = useState(false); 119 const [yamlSaving, setYamlSaving] = useState(false); 120 const [activeCategory, setActiveCategory] = useState<string>(""); 121 const { toast, showToast } = useToast(); 122 const fileInputRef = useRef<HTMLInputElement>(null); 123 const { t } = useI18n(); 124 const { setEnd } = usePageHeader(); 125 126 useLayoutEffect(() => { 127 if (!config || !schema) { 128 setEnd(null); 129 return; 130 } 131 setEnd( 132 <div className="relative w-full min-w-0 sm:max-w-xs"> 133 <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> 134 <Input 135 className="h-8 pl-8 pr-7 text-xs" 136 placeholder={t.common.search} 137 value={searchQuery} 138 onChange={(e) => setSearchQuery(e.target.value)} 139 /> 140 {searchQuery && ( 141 <Button 142 ghost 143 size="xs" 144 className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 145 onClick={() => setSearchQuery("")} 146 aria-label={t.common.clear} 147 > 148 <X /> 149 </Button> 150 )} 151 </div>, 152 ); 153 return () => setEnd(null); 154 }, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]); 155 156 function prettyCategoryName(cat: string): string { 157 const key = cat as keyof typeof t.config.categories; 158 if (t.config.categories[key]) return t.config.categories[key]; 159 return cat.charAt(0).toUpperCase() + cat.slice(1); 160 } 161 162 useEffect(() => { 163 api 164 .getConfig() 165 .then(setConfig) 166 .catch(() => {}); 167 api 168 .getSchema() 169 .then((resp) => { 170 setSchema(resp.fields as Record<string, Record<string, unknown>>); 171 setCategoryOrder(resp.category_order ?? []); 172 }) 173 .catch(() => {}); 174 api 175 .getDefaults() 176 .then(setDefaults) 177 .catch(() => {}); 178 }, []); 179 180 // Set active category when categories load 181 useEffect(() => { 182 if (categoryOrder.length > 0 && !activeCategory) { 183 setActiveCategory(categoryOrder[0]); 184 } 185 }, [categoryOrder, activeCategory]); 186 187 // Load YAML when switching to YAML mode 188 useEffect(() => { 189 if (yamlMode) { 190 setYamlLoading(true); 191 api 192 .getConfigRaw() 193 .then((resp) => setYamlText(resp.yaml)) 194 .catch(() => showToast(t.config.failedToLoadRaw, "error")) 195 .finally(() => setYamlLoading(false)); 196 } 197 }, [yamlMode]); 198 199 /* ---- Categories ---- */ 200 const categories = useMemo(() => { 201 if (!schema) return []; 202 const allCats = [ 203 ...new Set( 204 Object.values(schema).map((s) => String(s.category ?? "general")), 205 ), 206 ]; 207 const ordered = categoryOrder.filter((c) => allCats.includes(c)); 208 const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort(); 209 return [...ordered, ...extra]; 210 }, [schema, categoryOrder]); 211 212 /* ---- Category field counts ---- */ 213 const categoryCounts = useMemo(() => { 214 if (!schema) return {}; 215 const counts: Record<string, number> = {}; 216 for (const s of Object.values(schema)) { 217 const cat = String(s.category ?? "general"); 218 counts[cat] = (counts[cat] || 0) + 1; 219 } 220 return counts; 221 }, [schema]); 222 223 /* ---- Search ---- */ 224 const isSearching = searchQuery.trim().length > 0; 225 const lowerSearch = searchQuery.toLowerCase(); 226 227 const searchMatchedFields = useMemo(() => { 228 if (!isSearching || !schema) return []; 229 return Object.entries(schema).filter(([key, s]) => { 230 const label = key.split(".").pop() ?? key; 231 const humanLabel = label.replace(/_/g, " "); 232 return ( 233 key.toLowerCase().includes(lowerSearch) || 234 humanLabel.toLowerCase().includes(lowerSearch) || 235 String(s.category ?? "") 236 .toLowerCase() 237 .includes(lowerSearch) || 238 String(s.description ?? "") 239 .toLowerCase() 240 .includes(lowerSearch) 241 ); 242 }); 243 }, [isSearching, lowerSearch, schema]); 244 245 /* ---- Active tab fields ---- */ 246 const activeFields = useMemo(() => { 247 if (!schema || isSearching) return []; 248 return Object.entries(schema).filter( 249 ([, s]) => String(s.category ?? "general") === activeCategory, 250 ); 251 }, [schema, activeCategory, isSearching]); 252 253 /* ---- Handlers ---- */ 254 const handleSave = async () => { 255 if (!config) return; 256 setSaving(true); 257 try { 258 await api.saveConfig(config); 259 showToast(t.config.configSaved, "success"); 260 } catch (e) { 261 showToast(`${t.config.failedToSave}: ${e}`, "error"); 262 } finally { 263 setSaving(false); 264 } 265 }; 266 267 const handleYamlSave = async () => { 268 setYamlSaving(true); 269 try { 270 await api.saveConfigRaw(yamlText); 271 showToast(t.config.yamlConfigSaved, "success"); 272 api 273 .getConfig() 274 .then(setConfig) 275 .catch(() => {}); 276 } catch (e) { 277 showToast(`${t.config.failedToSaveYaml}: ${e}`, "error"); 278 } finally { 279 setYamlSaving(false); 280 } 281 }; 282 283 const handleReset = () => { 284 if (!defaults || !config) return; 285 // Scope the reset to what the user is currently looking at: 286 // - search mode → the matched fields 287 // - form mode → the active category's fields 288 // Resetting the whole config here was a footgun (issue reported by @ykmfb001): 289 // the button sits next to the category tabs and users reasonably assumed 290 // "reset this tab", not "wipe my entire config.yaml". 291 const scopedFields = isSearching ? searchMatchedFields : activeFields; 292 if (scopedFields.length === 0) return; 293 const scopeLabel = isSearching 294 ? t.config.searchResults 295 : prettyCategoryName(activeCategory); 296 const message = t.config.confirmResetScope.replace("{scope}", scopeLabel); 297 if (!window.confirm(message)) return; 298 let next: Record<string, unknown> = config; 299 for (const [key] of scopedFields) { 300 next = setNestedValue(next, key, getNestedValue(defaults, key)); 301 } 302 setConfig(next); 303 showToast( 304 t.config.resetScopeToast.replace("{scope}", scopeLabel), 305 "success", 306 ); 307 }; 308 309 const handleExport = () => { 310 if (!config) return; 311 const blob = new Blob([JSON.stringify(config, null, 2)], { 312 type: "application/json", 313 }); 314 const url = URL.createObjectURL(blob); 315 const a = document.createElement("a"); 316 a.href = url; 317 a.download = "hermes-config.json"; 318 a.click(); 319 URL.revokeObjectURL(url); 320 }; 321 322 const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => { 323 const file = e.target.files?.[0]; 324 if (!file) return; 325 const reader = new FileReader(); 326 reader.onload = () => { 327 try { 328 const imported = JSON.parse(reader.result as string); 329 setConfig(imported); 330 showToast(t.config.configImported, "success"); 331 } catch { 332 showToast(t.config.invalidJson, "error"); 333 } 334 }; 335 reader.readAsText(file); 336 }; 337 338 /* ---- Loading ---- */ 339 if (!config || !schema) { 340 return ( 341 <div className="flex items-center justify-center py-24"> 342 <Spinner className="text-2xl text-primary" /> 343 </div> 344 ); 345 } 346 347 /* ---- Render field list (shared between search & normal) ---- */ 348 const renderFields = ( 349 fields: [string, Record<string, unknown>][], 350 showCategory = false, 351 ) => { 352 let lastSection = ""; 353 let lastCat = ""; 354 return fields.map(([key, s]) => { 355 const parts = key.split("."); 356 const section = parts.length > 1 ? parts[0] : ""; 357 const cat = String(s.category ?? "general"); 358 const showCatBadge = showCategory && cat !== lastCat; 359 const showSection = 360 !showCategory && 361 section && 362 section !== lastSection && 363 section !== activeCategory; 364 lastSection = section; 365 lastCat = cat; 366 367 return ( 368 <div key={key}> 369 {showCatBadge && ( 370 <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0"> 371 <CategoryIcon 372 category={cat} 373 className="h-4 w-4 text-muted-foreground" 374 /> 375 <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> 376 {prettyCategoryName(cat)} 377 </span> 378 <div className="flex-1 border-t border-border" /> 379 </div> 380 )} 381 {showSection && ( 382 <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0"> 383 <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> 384 {section.replace(/_/g, " ")} 385 </span> 386 <div className="flex-1 border-t border-border" /> 387 </div> 388 )} 389 <div className="py-1"> 390 <AutoField 391 schemaKey={key} 392 schema={s} 393 value={getNestedValue(config, key)} 394 onChange={(v) => setConfig(setNestedValue(config, key, v))} 395 /> 396 </div> 397 </div> 398 ); 399 }); 400 }; 401 402 return ( 403 <div className="flex flex-col gap-4"> 404 <PluginSlot name="config:top" /> 405 <Toast toast={toast} /> 406 407 <div className="flex items-center justify-between gap-4"> 408 <div className="flex items-center gap-2"> 409 <Settings2 className="h-4 w-4 text-muted-foreground" /> 410 <code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5"> 411 {t.config.configPath} 412 </code> 413 </div> 414 <div className="flex items-center gap-1.5"> 415 <Button 416 ghost 417 size="icon" 418 onClick={handleExport} 419 title={t.config.exportConfig} 420 aria-label={t.config.exportConfig} 421 > 422 <Download /> 423 </Button> 424 <Button 425 ghost 426 size="icon" 427 onClick={() => fileInputRef.current?.click()} 428 title={t.config.importConfig} 429 aria-label={t.config.importConfig} 430 > 431 <Upload /> 432 </Button> 433 <input 434 ref={fileInputRef} 435 type="file" 436 accept=".json" 437 className="hidden" 438 onChange={handleImport} 439 /> 440 {!yamlMode && 441 (() => { 442 const resetScopeLabel = isSearching 443 ? t.config.searchResults 444 : prettyCategoryName(activeCategory); 445 const resetTitle = t.config.resetScopeTooltip.replace( 446 "{scope}", 447 resetScopeLabel, 448 ); 449 return ( 450 <Button 451 ghost 452 size="icon" 453 onClick={handleReset} 454 title={resetTitle} 455 aria-label={resetTitle} 456 > 457 <RotateCcw /> 458 </Button> 459 ); 460 })()} 461 462 <div className="w-px h-5 bg-border mx-1" /> 463 464 <Button 465 size="sm" 466 outlined={!yamlMode} 467 onClick={() => setYamlMode(!yamlMode)} 468 prefix={yamlMode ? <FormInput /> : <Code />} 469 > 470 {yamlMode ? t.common.form : "YAML"} 471 </Button> 472 473 {yamlMode ? ( 474 <Button 475 size="sm" 476 onClick={handleYamlSave} 477 disabled={yamlSaving} 478 prefix={<Save />} 479 > 480 {yamlSaving ? t.common.saving : t.common.save} 481 </Button> 482 ) : ( 483 <Button 484 size="sm" 485 onClick={handleSave} 486 disabled={saving} 487 prefix={<Save />} 488 > 489 {saving ? t.common.saving : t.common.save} 490 </Button> 491 )} 492 </div> 493 </div> 494 495 {yamlMode ? ( 496 <Card> 497 <CardHeader className="py-3 px-4"> 498 <CardTitle className="text-sm flex items-center gap-2"> 499 <FileText className="h-4 w-4" /> 500 {t.config.rawYaml} 501 </CardTitle> 502 </CardHeader> 503 <CardContent className="p-0"> 504 {yamlLoading ? ( 505 <div className="flex items-center justify-center py-12"> 506 <Spinner className="text-xl text-primary" /> 507 </div> 508 ) : ( 509 <textarea 510 className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border" 511 value={yamlText} 512 onChange={(e) => setYamlText(e.target.value)} 513 spellCheck={false} 514 /> 515 )} 516 </CardContent> 517 </Card> 518 ) : ( 519 <div className="flex flex-col sm:flex-row gap-4"> 520 <aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0"> 521 <div className="sm:sticky sm:top-4"> 522 <div className="flex flex-col border border-border bg-muted/20"> 523 <div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border"> 524 <Filter className="h-3 w-3 text-muted-foreground" /> 525 <span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground"> 526 {t.config.filters} 527 </span> 528 </div> 529 530 <div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70"> 531 {t.config.sections} 532 </div> 533 534 <div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto"> 535 {categories.map((cat) => { 536 const isActive = !isSearching && activeCategory === cat; 537 538 return ( 539 <ListItem 540 key={cat} 541 active={isActive} 542 onClick={() => { 543 setSearchQuery(""); 544 setActiveCategory(cat); 545 }} 546 className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]" 547 > 548 <CategoryIcon 549 category={cat} 550 className="h-3.5 w-3.5 shrink-0" 551 /> 552 <span className="flex-1 truncate"> 553 {prettyCategoryName(cat)} 554 </span> 555 <span 556 className={`text-[10px] tabular-nums ${ 557 isActive 558 ? "text-foreground/60" 559 : "text-muted-foreground/50" 560 }`} 561 > 562 {categoryCounts[cat] || 0} 563 </span> 564 </ListItem> 565 ); 566 })} 567 </div> 568 </div> 569 </div> 570 </aside> 571 572 <div className="flex-1 min-w-0"> 573 {isSearching ? ( 574 <Card> 575 <CardHeader className="py-3 px-4"> 576 <div className="flex items-center justify-between"> 577 <CardTitle className="text-sm flex items-center gap-2"> 578 <Search className="h-4 w-4" /> 579 {t.config.searchResults} 580 </CardTitle> 581 <Badge tone="secondary" className="text-[10px]"> 582 {searchMatchedFields.length}{" "} 583 {t.config.fields.replace( 584 "{s}", 585 searchMatchedFields.length !== 1 ? "s" : "", 586 )} 587 </Badge> 588 </div> 589 </CardHeader> 590 <CardContent className="grid gap-2 px-4 pb-4"> 591 {searchMatchedFields.length === 0 ? ( 592 <p className="text-sm text-muted-foreground text-center py-8"> 593 {t.config.noFieldsMatch.replace("{query}", searchQuery)} 594 </p> 595 ) : ( 596 renderFields(searchMatchedFields, true) 597 )} 598 </CardContent> 599 </Card> 600 ) : ( 601 /* Active category */ 602 <Card> 603 <CardHeader className="py-3 px-4"> 604 <div className="flex items-center justify-between"> 605 <CardTitle className="text-sm flex items-center gap-2"> 606 <CategoryIcon 607 category={activeCategory} 608 className="h-4 w-4" 609 /> 610 {prettyCategoryName(activeCategory)} 611 </CardTitle> 612 <Badge tone="secondary" className="text-[10px]"> 613 {activeFields.length}{" "} 614 {t.config.fields.replace( 615 "{s}", 616 activeFields.length !== 1 ? "s" : "", 617 )} 618 </Badge> 619 </div> 620 </CardHeader> 621 <CardContent className="grid gap-2 px-4 pb-4"> 622 {renderFields(activeFields)} 623 </CardContent> 624 </Card> 625 )} 626 </div> 627 </div> 628 )} 629 <PluginSlot name="config:bottom" /> 630 </div> 631 ); 632 }