/ web / src / pages / ConfigPage.tsx
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  }