diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index bf5c25d1..74758980 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -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" : ""); - 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({ 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 && } - + + +
+ + chevron_right + Model Roles (optional) + +
+ {HERMES_ROLES.map((role) => ( +
+ {role.label} + arrow_forward +
+ 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] && ( + + )} +
+ +
+ ))} +

Empty roles inherit the default model.

+
+
{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}` : ""}`} /> )} diff --git a/src/app/api/cli-tools/hermes-settings/route.js b/src/app/api/cli-tools/hermes-settings/route.js index 1636de4e..64844141 100644 --- a/src/app/api/cli-tools/hermes-settings/route.js +++ b/src/app/api/cli-tools/hermes-settings/route.js @@ -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 }); diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 77c307fb..3f521231 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -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",