feat(hermes): multi-role model config (delegation + auxiliary slots)

POST /api/cli-tools/hermes-settings now accepts selections:
[{role, model}] and writes model/delegation/auxiliary.<role> blocks
into ~/.hermes/config.yaml non-destructively (comments and other keys
preserved). GET returns the current delegation/auxiliary config,
DELETE removes all 9router-managed blocks. Dashboard card gains a
collapsible Model Roles section aligned with the existing rows; the
bare `model` payload from the CLI quick setup still works.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decolua
2026-09-23 11:46:21 +07:00
parent 001b292876
commit 69cc1aa987
3 changed files with 193 additions and 15 deletions

View File

@@ -7,8 +7,10 @@ import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
const ENDPOINT = "/api/cli-tools/hermes-settings";
const HERMES_ROLES = CLI_TOOLS.hermes?.roles || [];
export default function HermesToolCard({
tool,
@@ -32,6 +34,8 @@ export default function HermesToolCard({
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [roleModels, setRoleModels] = useState({});
const [modalTarget, setModalTarget] = useState("default");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
@@ -82,6 +86,12 @@ export default function HermesToolCard({
hasInitializedModel.current = true;
const cfg = hermesStatus.settings?.model;
if (cfg?.default) setSelectedModel(cfg.default);
const initial = {};
if (hermesStatus.settings?.delegation?.model) initial.delegation = hermesStatus.settings.delegation.model;
for (const [role, rcfg] of Object.entries(hermesStatus.settings?.auxiliary || {})) {
if (rcfg?.model) initial[role] = rcfg.model;
}
setRoleModels(initial);
}
}, [hermesStatus]);
@@ -126,7 +136,12 @@ export default function HermesToolCard({
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
selections: [
{ role: "default", model: selectedModel },
...Object.entries(roleModels)
.filter(([, model]) => model?.trim())
.map(([role, model]) => ({ role, model: model.trim() })),
],
}),
});
const data = await res.json();
@@ -154,6 +169,7 @@ export default function HermesToolCard({
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setRoleModels({});
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -166,16 +182,35 @@ export default function HermesToolCard({
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
if (modalTarget === "default") {
setSelectedModel(model.value);
} else {
setRoleModels((prev) => ({ ...prev, [modalTarget]: model.value }));
}
setModalOpen(false);
};
const openModelModal = (target) => {
setModalTarget(target);
setModalOpen(true);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
const base = getEffectiveBaseUrl();
let yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`;
if (roleModels.delegation?.trim()) {
yamlContent += `delegation:\n model: "${roleModels.delegation.trim()}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`;
}
const auxRoles = Object.entries(roleModels).filter(([role, model]) => role !== "delegation" && model?.trim());
if (auxRoles.length > 0) {
yamlContent += `auxiliary:\n${auxRoles.map(([role, model]) =>
` ${role}:\n provider: "custom"\n model: "${model.trim()}"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`
).join("")}`;
}
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
return [
@@ -274,8 +309,49 @@ export default function HermesToolCard({
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
<button onClick={() => openModelModal("default")} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
<details className="group">
<summary className="cursor-pointer select-none text-xs font-semibold text-text-main hover:text-primary transition-colors">
<span className="material-symbols-outlined align-middle text-[16px] text-text-muted group-open:rotate-90 transition-transform">chevron_right</span>
Model Roles (optional)
</summary>
<div className="mt-2 flex flex-col gap-1.5">
{HERMES_ROLES.map((role) => (
<div key={role.id} className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="truncate text-xs font-semibold text-text-main sm:text-right sm:text-sm" title={role.label}>{role.label}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={roleModels[role.id] || ""}
onChange={(e) => setRoleModels((prev) => ({ ...prev, [role.id]: e.target.value }))}
placeholder="inherit default"
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{roleModels[role.id] && (
<button
onClick={() => setRoleModels((prev) => ({ ...prev, [role.id]: "" }))}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => openModelModal(role.id)}
disabled={!hasActiveProviders}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
</div>
))}
<p className="text-xs text-text-muted">Empty roles inherit the default model.</p>
</div>
</details>
</div>
{message && (
@@ -306,10 +382,10 @@ export default function HermesToolCard({
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
selectedModel={modalTarget === "default" ? selectedModel : roleModels[modalTarget] || ""}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Hermes Agent"
title={`Select Model for Hermes Agent${modalTarget !== "default" ? ` — ${HERMES_ROLES.find((r) => r.id === modalTarget)?.label || modalTarget}` : ""}`}
/>
)}

View File

@@ -18,10 +18,20 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
// Match top-level "model:" block (until next non-indented, non-empty line)
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
const DELEGATION_BLOCK_RE = /^delegation:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
// "auxiliary:" block; children are 2-space-indented role keys with 4+-space fields
const AUX_BLOCK_RE = /^auxiliary:[ \t]*\r?\n((?:(?:[ \t]+.*\r?\n?)|(?:[ \t]*\r?\n))*)/m;
const auxRoleRe = (role) => new RegExp(`^ ${role}:[ \\t]*\\r?\\n(?:(?:[ \\t]{4,}.*\\r?\\n?)|(?:[ \\t]*\\r?\\n))*`, "m");
const buildModelBlock = (model, baseUrl) =>
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
const buildDelegationBlock = (model, baseUrl) =>
`delegation:\n model: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
const buildAuxRoleBlock = (role, model, baseUrl) =>
` ${role}:\n provider: "custom"\n model: "${model}"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
// Parse current model block back to fields (best-effort, simple key:value)
const parseModelBlock = (yaml) => {
const match = yaml.match(MODEL_BLOCK_RE);
@@ -44,6 +54,60 @@ const upsertModelBlock = (yaml, newBlock) => {
return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock;
};
const upsertDelegationBlock = (yaml, newBlock) => {
if (DELEGATION_BLOCK_RE.test(yaml)) return yaml.replace(DELEGATION_BLOCK_RE, newBlock);
return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${newBlock}` : `${yaml}\n${newBlock}`;
};
const removeDelegationBlock = (yaml) => yaml.replace(DELEGATION_BLOCK_RE, "");
const upsertAuxRole = (yaml, role, roleBlock) => {
const re = auxRoleRe(role);
const m = yaml.match(AUX_BLOCK_RE);
if (!m) {
const block = `auxiliary:\n${roleBlock}`;
return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${block}` : `${yaml}\n${block}`;
}
const body = re.test(m[1]) ? m[1].replace(re, roleBlock) : `${m[1]}${roleBlock}`;
return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`);
};
const removeAuxRole = (yaml, role) => {
const m = yaml.match(AUX_BLOCK_RE);
if (!m) return yaml;
const body = m[1].replace(auxRoleRe(role), "");
if (body.trim() === "") return yaml.replace(AUX_BLOCK_RE, "");
return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`);
};
// role -> { model, provider, base_url } for every entry under "auxiliary:"
const parseAuxRoles = (yaml) => {
const m = yaml.match(AUX_BLOCK_RE);
if (!m) return {};
const roles = {};
const subRe = /^ ([A-Za-z0-9_]+):[ \t]*\r?\n((?:(?:[ \t]{4,}.*\r?\n?)|(?:[ \t]*\r?\n))*)/gm;
let sm;
while ((sm = subRe.exec(m[1]))) {
const get = (key) => {
const km = sm[2].match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m"));
return km ? km[1].trim() : null;
};
roles[sm[1]] = { model: get("model"), provider: get("provider"), base_url: get("base_url") };
}
return roles;
};
const parseDelegationBlock = (yaml) => {
const match = yaml.match(DELEGATION_BLOCK_RE);
if (!match) return null;
const body = match[1] || "";
const get = (key) => {
const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m"));
return m ? m[1].trim() : null;
};
return { model: get("model"), provider: get("provider"), base_url: get("base_url") };
};
const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, "");
// .env helpers — upsert/remove single KEY=VALUE line
@@ -107,10 +171,12 @@ export async function GET() {
}
const yaml = await readConfigYaml();
const model = parseModelBlock(yaml);
const delegation = parseDelegationBlock(yaml);
const auxiliary = parseAuxRoles(yaml);
return NextResponse.json({
installed: true,
settings: { model },
has9Router: has9RouterConfig(model),
settings: { model, delegation, auxiliary },
has9Router: has9RouterConfig(model) || has9RouterConfig(delegation) || Object.values(auxiliary).some(has9RouterConfig),
configPath: getHermesConfigPath(),
});
} catch (error) {
@@ -121,8 +187,14 @@ export async function GET() {
export async function POST(request) {
try {
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !model) {
const { baseUrl, apiKey, model, selections } = await request.json();
// selections: [{role, model}] — "default" plus any auxiliary/delegation slots.
// Legacy callers (CLI quick setup) send a bare `model` → treat as default role.
const sel = Array.isArray(selections) && selections.some((s) => s?.role && s?.model)
? selections.filter((s) => s?.role && s?.model)
: model ? [{ role: "default", model }] : [];
const defaultSel = sel.find((s) => s.role === "default");
if (!baseUrl || !defaultSel) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
}
@@ -131,9 +203,17 @@ export async function POST(request) {
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
// Update config.yaml — replace/insert model: block, keep everything else
const existingYaml = await readConfigYaml();
const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl));
// Update config.yaml — upsert each role block, keep everything else
let newYaml = await readConfigYaml();
for (const { role, model: roleModel } of sel) {
if (role === "default") {
newYaml = upsertModelBlock(newYaml, buildModelBlock(roleModel, normalizedBaseUrl));
} else if (role === "delegation") {
newYaml = upsertDelegationBlock(newYaml, buildDelegationBlock(roleModel, normalizedBaseUrl));
} else {
newYaml = upsertAuxRole(newYaml, role, buildAuxRoleBlock(role, roleModel, normalizedBaseUrl));
}
}
await fs.writeFile(getHermesConfigPath(), newYaml);
// Update .env — upsert OPENAI_API_KEY only when caller provides one
@@ -166,9 +246,15 @@ export async function DELETE() {
}
throw error;
}
const newYaml = removeModelBlock(yaml);
let newYaml = removeModelBlock(yaml);
newYaml = removeDelegationBlock(newYaml);
// Only drop auxiliary entries we manage (custom provider, any base URL — covers tunnels)
for (const [role, cfg] of Object.entries(parseAuxRoles(yaml))) {
if (cfg?.provider === "custom") newYaml = removeAuxRole(newYaml, role);
}
newYaml = newYaml.replace(/^\n+/, "");
await fs.writeFile(configPath, newYaml);
return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` });
return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model blocks removed` });
} catch (error) {
console.log("Error resetting hermes settings:", error);
return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 });

View File

@@ -165,6 +165,22 @@ export const CLI_TOOLS = {
color: "#8B5CF6",
description: "Nous Research self-improving AI agent",
configType: "custom",
// Model slots Hermes supports besides the default ("model:" block).
// "default" is not listed — the card renders it as the main model picker.
roles: [
{ id: "delegation", label: "Delegation (subagents)" },
{ id: "vision", label: "Vision" },
{ id: "web_extract", label: "Web Extract" },
{ id: "compression", label: "Compression" },
{ id: "title_generation", label: "Title Generation" },
{ id: "approval", label: "Approval" },
{ id: "skills_hub", label: "Skills Hub" },
{ id: "mcp", label: "MCP" },
{ id: "memory_query_rewrite", label: "Memory Query Rewrite" },
{ id: "background_review", label: "Background Review" },
{ id: "curator", label: "Curator" },
{ id: "monitor", label: "Monitor" },
],
},
droid: {
id: "droid",