"use client"; import React, { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal } from "@/shared/components"; interface Role { id: string; label: string; description: string; } const HERMES_ROLES: Role[] = [ { id: "default", label: "Default (main)", description: "Primary conversation model" }, { id: "delegation", label: "Delegation (subagents)", description: "Orchestrator and sub-agent model", }, { id: "vision", label: "Vision", description: "Image and screenshot understanding" }, { id: "compression", label: "Compression", description: "Prompt compression & summarization" }, { id: "web_extract", label: "Web Extract", description: "Web page content extraction" }, { id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" }, { id: "approval", label: "Approval", description: "Safety and approval decisions" }, ]; const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"]; export default function HermesAgentToolCard({ tool, isExpanded = false, onToggle = () => {}, baseUrl, apiKeys, activeProviders = [], hasActiveProviders, cloudEnabled, batchStatus, }: any) { type RoleSelection = { model: string; provider: string }; const [selections, setSelections] = useState>({}); const [currentRoles, setCurrentRoles] = useState>({}); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [message, setMessage] = useState(null); const [modalRole, setModalRole] = useState(null); const [previewYaml, setPreviewYaml] = useState(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [firstSetupAt, setFirstSetupAt] = useState(null); // Track whether we have already seeded from batchStatus on this expand const seededFromBatchRef = useRef(false); function formatTimeSince(iso: string): string { const then = new Date(iso).getTime(); const diff = Date.now() - then; const days = Math.floor(diff / (1000 * 60 * 60 * 24)); if (days > 0) return `${days}d`; const hours = Math.floor(diff / (1000 * 60 * 60)); if (hours > 0) return `${hours}h`; const minutes = Math.floor(diff / (1000 * 60)); return `${minutes}m`; } useEffect(() => { if (isExpanded) { // Phase 3: Seed from detector snapshot (batchStatus) for instant UI if ( !seededFromBatchRef.current && Object.keys(currentRoles).length === 0 && batchStatus?.hermesAgentRoles ) { const seeded: Record = {}; Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => { seeded[role] = { model: info.model, provider: info.provider, }; }); setCurrentRoles(seeded); seededFromBatchRef.current = true; } loadCurrentConfig(); } else { // Reset seed flag when collapsed so it can seed again on next expand seededFromBatchRef.current = false; setPreviewYaml(null); setFirstSetupAt(null); } }, [isExpanded, batchStatus, currentRoles]); const loadCurrentConfig = async () => { setIsLoading(true); try { const res = await fetch("/api/cli-tools/hermes-agent-settings"); const data = await res.json(); if (data.success && data.roles) { setCurrentRoles(data.roles); if (data.firstSetupAt) setFirstSetupAt(data.firstSetupAt); // Do NOT seed selections from disk data. // selections only holds explicit user choices made in this session. // Display falls back to currentRoles for unchanged roles. } } catch (e) { console.warn("Could not load current Hermes Agent config", e); } finally { setIsLoading(false); } }; const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => { setSelections((prev) => ({ ...prev, [roleId]: { model, provider } })); }; const applyToAll = (model: string) => { const newSel: Record = {}; HERMES_ROLES.forEach((r) => (newSel[r.id] = { model, provider: "OmniRoute" })); setSelections(newSel); }; const handleTogglePreview = async () => { setMessage(null); // If preview is currently visible, hide it (toggle behavior) if (previewYaml) { setPreviewYaml(null); return; } // Build payload: prefer pending selections, fall back to currently loaded roles let payloadSelections: Array<{ role: string; model: string }>; if (Object.keys(selections).length > 0) { payloadSelections = Object.entries(selections).map(([role, sel]) => ({ role, model: sel.model, })); } else { payloadSelections = Object.entries(currentRoles) .filter(([_, info]) => info && info.model) .map(([role, info]) => ({ role, model: info.model })); } if (payloadSelections.length === 0) { setMessage("Select models for roles (or ensure roles are loaded) before previewing."); return; } setIsPreviewLoading(true); try { const res = await fetch("/api/cli-tools/hermes-agent-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl, keyId: apiKeys?.[0]?.id, selections: payloadSelections, preview: true, }), }); const data = await res.json(); if (res.ok && data.yaml) { setPreviewYaml(data.yaml); } else { setMessage(data.error || "Failed to generate preview"); } } catch { setMessage("Failed to generate preview"); } finally { setIsPreviewLoading(false); } }; const handleSave = async () => { setIsSaving(true); setMessage(null); const payloadSelections = Object.entries(selections).map(([role, sel]) => ({ role, model: sel.model, })); try { const res = await fetch("/api/cli-tools/hermes-agent-settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl, keyId: apiKeys?.[0]?.id, selections: payloadSelections, }), }); const data = await res.json(); if (res.ok) { setMessage(`Saved to ${data.configPath}`); setSelections({}); // clear pending user choices after successful save setPreviewYaml(null); // hide any open preview after apply await loadCurrentConfig(); } else { setMessage(data.error || "Failed to save"); } } catch { setMessage("Network error"); } finally { setIsSaving(false); } }; const isLoadingAny = isLoading || isSaving; // Effective per-role data for count + collapsed status. // Priority: pending selections > freshly loaded currentRoles > batchStatus from detector (phase 3) const effectiveRoles = React.useMemo(() => { // If user has pending changes, treat selected roles as OmniRoute if (Object.keys(selections).length > 0) { const map: Record = {}; HERMES_ROLES.forEach((r) => { if (selections[r.id]) { map[r.id] = { usingOmniRoute: true }; } else if (currentRoles[r.id]) { map[r.id] = currentRoles[r.id]; } else if (batchStatus?.hermesAgentRoles?.[r.id]) { map[r.id] = batchStatus.hermesAgentRoles[r.id]; } }); return map; } // Prefer fresh loaded data if (Object.keys(currentRoles).length > 0) { return currentRoles; } // Fall back to detector snapshot (this is what finishes phase 3) return batchStatus?.hermesAgentRoles || {}; }, [selections, currentRoles, batchStatus]); // Count of roles that are (or will be) routed via OmniRoute const configuredRolesCount = HERMES_ROLES.filter((role) => { // Pending selection always counts as OmniRoute intent if (selections[role.id]) return true; const info = effectiveRoles[role.id]; if (!info) return false; // Support both shapes: detector shape (usingOmniRoute) and settings shape (provider + base_url) if (typeof info.usingOmniRoute === "boolean") { return info.usingOmniRoute; } return ( info?.provider === "omniroute" || (info?.base_url || "").includes("20128") || (info?.base_url || "").includes("localhost") ); }).length; return ( {/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */}
terminal

{tool?.name || "Hermes Agent"} {firstSetupAt && ( schedule {formatTimeSince(firstSetupAt)} since setup )}

{(Object.keys(currentRoles).length > 0 || Object.keys(selections).length > 0 || Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && ( {configuredRolesCount}/{HERMES_ROLES.length} roles )}

{tool?.description || "Advanced multi-role terminal agent (by Nousresearch)"}

expand_more
{isExpanded && (
{/* Right-aligned Refresh button (no counter) */}
{/* Quick apply row — consistent small action pills */} {activeProviders?.[0]?.models?.length > 0 && (
Quick apply same model to all roles: {activeProviders[0].models.slice(0, 6).map((m: any) => { const modelValue = typeof m === "string" ? m : m?.value || m?.name; if (!modelValue) return null; return ( ); })}
)} {/* Roles list — flat consistent rows (no nested Card.Section boxes) */}
{HERMES_ROLES.map((role) => { const current = currentRoles[role.id]; const sel = selections[role.id]; // displayed model prefers pending user choice, falls back to real current from YAML const displayedModel = sel?.model || current?.model; // Badge logic per user's spec: // - If user has selected something in this session (pending): show as via OmniRoute // - Else if current from disk: show real provider name + "(not OmniRoute)" or "OmniRoute" let badge: { label: string; pending: boolean } | null = null; if (sel) { // pending change made via the Select modal / quick apply → will be routed via OmniRoute const prov = sel.provider || "OmniRoute"; badge = { label: `${prov} (via OmniRoute)`, pending: true }; } else if (current) { const isOmni = current?.provider === "omniroute" || (current?.base_url || "").includes("20128") || (current?.base_url || "").includes("localhost"); if (isOmni) { badge = { label: "OmniRoute", pending: false }; } else { const realProvider = current.provider || "Other"; badge = { label: `${realProvider} (not OmniRoute)`, pending: false }; } } return (
{/* Left: role label + subtitle (now has room so long descriptions stay on one line) */}
{role.label}
{role.description}
{/* Right cluster: model name + status badge + actions (pushed to the right) */}
{displayedModel ? ( {displayedModel} ) : ( )} {badge && (
{badge.label} {badge.pending ? " *" : ""}
)} {sel && ( )}
); })}
{/* Message (standard colored info bar like other cards) */} {message && (
check_circle {message}
)} {/* Action row — primary Apply + right spacer for future actions */}
{Object.keys(selections).length > 0 && ( {Object.keys(selections).length} role {Object.keys(selections).length === 1 ? "" : "s"} will be updated )}
{/* Optional future: Reset or Manual config could go here, right-aligned */}
{/* Inline YAML preview — toggled by the Preview button, styled like other config previews on the CLI tools page */} {previewYaml && (
Preview — will write to ~/.hermes/config.yaml
                {previewYaml}
              
)}

Saves the selected models for each role into ~/.hermes/config.yaml.

)} setModalRole(null)} onSelect={(model: any) => { if (modalRole) { const modelValue = typeof model === "string" ? model : model?.value || model?.name; if (modelValue) { // Capture a useful provider label from the modal selection when available const prov = (model && (model.provider || model.providerId || model.group)) || "OmniRoute"; setRoleSelection(modalRole, modelValue, prov); } } setModalRole(null); }} showCombos={true} activeProviders={activeProviders} alwaysIncludeProviders={HERMES_AGENT_ZERO_CONFIG_PROVIDERS} /> ); }