* Port from Kilo-Org/kilocode#9448: roll up subagent costs into parent session total Child subagents built by delegate_task() each track their own session_estimated_cost_usd, but the parent agent's total never folded those numbers in. On runs where the parent mostly delegates and the children do the expensive work, the footer/UI was reporting a fraction of the actual spend — sometimes $0.00 when the parent itself made no billed calls. Fix: - Capture each child's session_estimated_cost_usd into _child_cost_usd on the result entry (before child.close() drops the counter). - After the existing subagent_stop hook loop, sum the children's costs and add the total to parent.session_estimated_cost_usd. - Promote session_cost_source from 'none' -> 'subagent' when the parent had no direct spend but children did, so the UI doesn't label the total as having unknown provenance. Real sources (openrouter, anthropic, etc.) are preserved. Nested orchestrator -> worker trees roll up naturally: each layer's own delegate_task() folds its direct children in, and when the orchestrator itself returns, its parent folds the orchestrator's now-inflated total on top. Internal fields (_child_cost_usd, _child_role) are stripped from the results dict before it's serialised back to the model — same contract as _child_role already followed. Tests: TestSubagentCostRollup (5 cases) covers single-child, batch, zero-cost-children, preserved-source, and legacy-fixture paths. Source: https://github.com/Kilo-Org/kilocode/pull/9448 * fix(web): scope dashboard config Reset button to the current tab Reported by @ykmfb001 via X: clicking 'Restore Defaults' (恢复默认值) on the Auxiliary page wiped the entire config.yaml to defaults, not just the auxiliary section. The button sits next to the category tabs and users reasonably assumed 'reset this tab', not 'reset everything'. Changes: - handleReset now scopes to the fields in the current view: active category's fields (form mode) or search-matched fields (search mode). Only those keys are copied from defaults; the rest of the config is left alone. - Added a window.confirm() with the scope name before applying. - Button is hidden in YAML mode (scoping doesn't apply there). - Tooltip/aria-label now name the scope, e.g. 'Reset Auxiliary to defaults'. - i18n: new resetScopeTooltip / confirmResetScope / resetScopeToast strings in en + zh; resetDefaults key preserved for compat.
541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
|
|
import {
|
|
Code,
|
|
Download,
|
|
FormInput,
|
|
RotateCcw,
|
|
Save,
|
|
Search,
|
|
Upload,
|
|
X,
|
|
Settings2,
|
|
FileText,
|
|
Settings,
|
|
Bot,
|
|
Monitor,
|
|
Palette,
|
|
Users,
|
|
Brain,
|
|
Package,
|
|
Lock,
|
|
Globe,
|
|
Mic,
|
|
Volume2,
|
|
Ear,
|
|
ClipboardList,
|
|
MessageCircle,
|
|
Wrench,
|
|
FileQuestion,
|
|
Filter,
|
|
} 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";
|
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
import { PluginSlot } from "@/plugins";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
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 <Icon className={className ?? "h-4 w-4"} />;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export default function ConfigPage() {
|
|
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
|
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
|
|
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
|
|
const [defaults, setDefaults] = useState<Record<string, unknown> | 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<string>("");
|
|
const { toast, showToast } = useToast();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const { t } = useI18n();
|
|
const { setEnd } = usePageHeader();
|
|
|
|
useLayoutEffect(() => {
|
|
if (!config || !schema) {
|
|
setEnd(null);
|
|
return;
|
|
}
|
|
setEnd(
|
|
<div className="relative w-full min-w-0 sm:max-w-xs">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="h-8 pl-8 pr-7 text-xs"
|
|
placeholder={t.common.search}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>,
|
|
);
|
|
return () => setEnd(null);
|
|
}, [config, schema, searchQuery, setEnd, t.common.search]);
|
|
|
|
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<string, Record<string, unknown>>);
|
|
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<string, number> = {};
|
|
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 || !config) return;
|
|
// Scope the reset to what the user is currently looking at:
|
|
// - search mode → the matched fields
|
|
// - form mode → the active category's fields
|
|
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
|
|
// the button sits next to the category tabs and users reasonably assumed
|
|
// "reset this tab", not "wipe my entire config.yaml".
|
|
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
|
if (scopedFields.length === 0) return;
|
|
const scopeLabel = isSearching
|
|
? t.config.searchResults
|
|
: prettyCategoryName(activeCategory);
|
|
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
|
|
if (!window.confirm(message)) return;
|
|
let next: Record<string, unknown> = config;
|
|
for (const [key] of scopedFields) {
|
|
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
|
}
|
|
setConfig(next);
|
|
showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success");
|
|
};
|
|
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="flex items-center justify-center py-24">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- Render field list (shared between search & normal) ---- */
|
|
const renderFields = (fields: [string, Record<string, unknown>][], 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 (
|
|
<div key={key}>
|
|
{showCatBadge && (
|
|
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
|
<CategoryIcon category={cat} className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{prettyCategoryName(cat)}
|
|
</span>
|
|
<div className="flex-1 border-t border-border" />
|
|
</div>
|
|
)}
|
|
{showSection && (
|
|
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{section.replace(/_/g, " ")}
|
|
</span>
|
|
<div className="flex-1 border-t border-border" />
|
|
</div>
|
|
)}
|
|
<div className="py-1">
|
|
<AutoField
|
|
schemaKey={key}
|
|
schema={s}
|
|
value={getNestedValue(config, key)}
|
|
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<PluginSlot name="config:top" />
|
|
<Toast toast={toast} />
|
|
|
|
{/* ═══════════════ Header Bar ═══════════════ */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
|
{t.config.configPath}
|
|
</code>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Button variant="ghost" size="sm" onClick={handleExport} title={t.config.exportConfig} aria-label={t.config.exportConfig}>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}>
|
|
<Upload className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
|
{!yamlMode && (() => {
|
|
const resetScopeLabel = isSearching
|
|
? t.config.searchResults
|
|
: prettyCategoryName(activeCategory);
|
|
const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel);
|
|
return (
|
|
<Button variant="ghost" size="sm" onClick={handleReset} title={resetTitle} aria-label={resetTitle}>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
);
|
|
})()}
|
|
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Button
|
|
variant={yamlMode ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setYamlMode(!yamlMode)}
|
|
className="gap-1.5"
|
|
>
|
|
{yamlMode ? (
|
|
<>
|
|
<FormInput className="h-3.5 w-3.5" />
|
|
{t.common.form}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Code className="h-3.5 w-3.5" />
|
|
YAML
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{yamlMode ? (
|
|
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
|
|
<Save className="h-3.5 w-3.5" />
|
|
{yamlSaving ? t.common.saving : t.common.save}
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
|
|
<Save className="h-3.5 w-3.5" />
|
|
{saving ? t.common.saving : t.common.save}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══════════════ YAML Mode ═══════════════ */}
|
|
{yamlMode ? (
|
|
<Card>
|
|
<CardHeader className="py-3 px-4">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<FileText className="h-4 w-4" />
|
|
{t.config.rawYaml}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{yamlLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
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"
|
|
value={yamlText}
|
|
onChange={(e) => setYamlText(e.target.value)}
|
|
spellCheck={false}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
/* ═══════════════ Form Mode ═══════════════ */
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
{/* ---- Filter panel ---- */}
|
|
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
|
|
<div className="sm:sticky sm:top-4">
|
|
<div className="flex flex-col border border-border bg-muted/20">
|
|
{/* Panel heading */}
|
|
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
|
<Filter className="h-3 w-3 text-muted-foreground" />
|
|
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
|
{t.config.filters}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
|
|
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
|
{t.config.sections}
|
|
</div>
|
|
|
|
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
|
|
<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">
|
|
{categories.map((cat) => {
|
|
const isActive = !isSearching && activeCategory === cat;
|
|
|
|
return (
|
|
<button
|
|
key={cat}
|
|
type="button"
|
|
onClick={() => {
|
|
setSearchQuery("");
|
|
setActiveCategory(cat);
|
|
}}
|
|
className={`
|
|
group flex items-center gap-2 px-2 py-1
|
|
rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
|
|
transition-colors
|
|
${
|
|
isActive
|
|
? "bg-foreground/10 text-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
|
}
|
|
`}
|
|
>
|
|
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
|
<span
|
|
className={`text-[10px] tabular-nums ${
|
|
isActive
|
|
? "text-foreground/60"
|
|
: "text-muted-foreground/50"
|
|
}`}
|
|
>
|
|
{categoryCounts[cat] || 0}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ---- Content ---- */}
|
|
<div className="flex-1 min-w-0">
|
|
{isSearching ? (
|
|
/* Search results */
|
|
<Card>
|
|
<CardHeader className="py-3 px-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Search className="h-4 w-4" />
|
|
{t.config.searchResults}
|
|
</CardTitle>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-2 px-4 pb-4">
|
|
{searchMatchedFields.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
|
|
</p>
|
|
) : (
|
|
renderFields(searchMatchedFields, true)
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
/* Active category */
|
|
<Card>
|
|
<CardHeader className="py-3 px-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<CategoryIcon category={activeCategory} className="h-4 w-4" />
|
|
{prettyCategoryName(activeCategory)}
|
|
</CardTitle>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-2 px-4 pb-4">
|
|
{renderFields(activeFields)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<PluginSlot name="config:bottom" />
|
|
</div>
|
|
);
|
|
}
|