Spaces:
Runtime error
Runtime error
| "use client"; | |
| import React, { useState, useEffect, useRef, useCallback } 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<Record<string, RoleSelection>>({}); | |
| const [currentRoles, setCurrentRoles] = useState<Record<string, any>>({}); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [message, setMessage] = useState<string | null>(null); | |
| const [modalRole, setModalRole] = useState<string | null>(null); | |
| const [previewYaml, setPreviewYaml] = useState<string | null>(null); | |
| const [isPreviewLoading, setIsPreviewLoading] = useState(false); | |
| const [firstSetupAt, setFirstSetupAt] = useState<string | null>(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`; | |
| } | |
| const loadCurrentConfig = useCallback(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); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| if (!isExpanded) { | |
| // Reset seed flag when collapsed so it can seed again on next expand | |
| seededFromBatchRef.current = false; | |
| setPreviewYaml(null); | |
| setFirstSetupAt(null); | |
| return; | |
| } | |
| // Phase 3: Seed from detector snapshot (batchStatus) for instant UI — once per expand. | |
| // NOTE: currentRoles is intentionally NOT a dependency. loadCurrentConfig() below sets | |
| // currentRoles to a fresh object on every fetch; if currentRoles were a dep, the effect | |
| // would re-fire → refetch → setCurrentRoles → re-fire … an infinite loop. On the detail | |
| // page isExpanded is hardcoded true, so that loop spun forever (the "loading forever" + | |
| // console spam of /api/cli-tools/hermes-agent-settings). We read currentRoles only via a | |
| // functional update so the emptiness guard sees the latest value without subscribing to it. | |
| if (!seededFromBatchRef.current && batchStatus?.hermesAgentRoles) { | |
| seededFromBatchRef.current = true; | |
| setCurrentRoles((prev) => { | |
| if (Object.keys(prev).length > 0) return prev; | |
| const seeded: Record<string, any> = {}; | |
| Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => { | |
| seeded[role] = { model: info.model, provider: info.provider }; | |
| }); | |
| return seeded; | |
| }); | |
| } | |
| loadCurrentConfig(); | |
| }, [isExpanded, batchStatus, loadCurrentConfig]); | |
| const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => { | |
| setSelections((prev) => ({ ...prev, [roleId]: { model, provider } })); | |
| }; | |
| const applyToAll = (model: string) => { | |
| const newSel: Record<string, RoleSelection> = {}; | |
| 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<string, any> = {}; | |
| 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 ( | |
| <Card padding="sm" className="overflow-hidden"> | |
| {/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */} | |
| <div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}> | |
| <div className="flex items-center gap-3"> | |
| <div className="size-8 flex items-center justify-center shrink-0"> | |
| <span className="material-symbols-outlined text-[22px] text-text-muted">terminal</span> | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-medium text-sm flex items-center gap-2"> | |
| {tool?.name || "Hermes Agent"} | |
| {firstSetupAt && ( | |
| <span | |
| className="text-[10px] text-text-muted flex items-center gap-0.5 font-normal" | |
| title={`First set up via OmniRoute on ${new Date(firstSetupAt).toLocaleDateString()}`} | |
| > | |
| <span className="material-symbols-outlined text-[11px]">schedule</span> | |
| {formatTimeSince(firstSetupAt)} since setup | |
| </span> | |
| )} | |
| </h3> | |
| {(Object.keys(currentRoles).length > 0 || | |
| Object.keys(selections).length > 0 || | |
| Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && ( | |
| <span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600"> | |
| {configuredRolesCount}/{HERMES_ROLES.length} roles | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-xs text-text-muted truncate"> | |
| {tool?.description || "Advanced multi-role terminal agent (by Nousresearch)"} | |
| </p> | |
| </div> | |
| </div> | |
| <span | |
| className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`} | |
| > | |
| expand_more | |
| </span> | |
| </div> | |
| {isExpanded && ( | |
| <div className="mt-4 pt-4 border-t border-border flex flex-col gap-4"> | |
| {/* Right-aligned Refresh button (no counter) */} | |
| <div className="flex justify-end"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={loadCurrentConfig} | |
| disabled={isLoading} | |
| loading={isLoading} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">refresh</span> | |
| Refresh all | |
| </Button> | |
| </div> | |
| {/* Quick apply row — consistent small action pills */} | |
| {activeProviders?.[0]?.models?.length > 0 && ( | |
| <div className="flex flex-wrap items-center gap-2 text-xs"> | |
| <span className="text-text-muted">Quick apply same model to all roles:</span> | |
| {activeProviders[0].models.slice(0, 6).map((m: any) => { | |
| const modelValue = typeof m === "string" ? m : m?.value || m?.name; | |
| if (!modelValue) return null; | |
| return ( | |
| <button | |
| key={modelValue} | |
| onClick={() => applyToAll(modelValue)} | |
| className="px-2 py-0.5 rounded border border-border bg-surface hover:bg-bg-secondary text-text-main transition-colors" | |
| title={`Apply ${modelValue} to every role`} | |
| > | |
| {modelValue} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Roles list — flat consistent rows (no nested Card.Section boxes) */} | |
| <div className="flex flex-col gap-2"> | |
| {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 ( | |
| <div key={role.id} className="flex items-start justify-between gap-3 py-1"> | |
| {/* Left: role label + subtitle (now has room so long descriptions stay on one line) */} | |
| <div className="min-w-0 pr-3"> | |
| <div className="font-medium text-sm text-text-main">{role.label}</div> | |
| <div className="text-[10px] leading-tight text-text-muted"> | |
| {role.description} | |
| </div> | |
| </div> | |
| {/* Right cluster: model name + status badge + actions (pushed to the right) */} | |
| <div className="flex items-center gap-2 shrink-0"> | |
| {displayedModel ? ( | |
| <code | |
| className="px-1.5 py-0.5 rounded bg-black/5 dark:bg-white/10 font-mono text-text-main text-[10px] max-w-[200px] truncate" | |
| title={displayedModel} | |
| > | |
| {displayedModel} | |
| </code> | |
| ) : ( | |
| <span className="text-text-muted text-[10px]">—</span> | |
| )} | |
| {badge && ( | |
| <div | |
| className={`text-[10px] px-1.5 py-px rounded shrink-0 ${ | |
| badge.label.includes("not OmniRoute") | |
| ? "bg-amber-500/10 text-amber-600" | |
| : "bg-emerald-500/10 text-emerald-600" | |
| }`} | |
| > | |
| {badge.label} | |
| {badge.pending ? " *" : ""} | |
| </div> | |
| )} | |
| <Button | |
| variant="secondary" | |
| size="sm" | |
| onClick={() => setModalRole(role.id)} | |
| disabled={isLoadingAny} | |
| > | |
| Select | |
| </Button> | |
| {sel && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => { | |
| setSelections((prev) => { | |
| const next = { ...prev }; | |
| delete next[role.id]; | |
| return next; | |
| }); | |
| }} | |
| disabled={isLoadingAny} | |
| title="Remove this role from pending changes" | |
| > | |
| Clear | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Message (standard colored info bar like other cards) */} | |
| {message && ( | |
| <div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-green-500/10 text-green-600"> | |
| <span className="material-symbols-outlined text-[14px]">check_circle</span> | |
| <span>{message}</span> | |
| </div> | |
| )} | |
| {/* Action row — primary Apply + right spacer for future actions */} | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| onClick={handleSave} | |
| disabled={isSaving || isLoading || Object.keys(selections).length === 0} | |
| variant="primary" | |
| size="sm" | |
| loading={isSaving} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">save</span> | |
| Apply to Hermes Agent | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={handleTogglePreview} | |
| disabled={ | |
| isSaving || | |
| isLoading || | |
| (Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0) | |
| } | |
| loading={isPreviewLoading} | |
| > | |
| <span className="material-symbols-outlined text-[14px] mr-1">visibility</span> | |
| Preview | |
| </Button> | |
| {Object.keys(selections).length > 0 && ( | |
| <span className="text-xs text-text-muted ml-1"> | |
| {Object.keys(selections).length} role | |
| {Object.keys(selections).length === 1 ? "" : "s"} will be updated | |
| </span> | |
| )} | |
| <div className="flex-1" /> | |
| {/* Optional future: Reset or Manual config could go here, right-aligned */} | |
| </div> | |
| {/* Inline YAML preview — toggled by the Preview button, styled like other config previews on the CLI tools page */} | |
| {previewYaml && ( | |
| <div className="mt-2"> | |
| <div className="text-[10px] font-medium text-text-muted mb-1.5 flex items-center gap-1.5"> | |
| <span>Preview — will write to ~/.hermes/config.yaml</span> | |
| </div> | |
| <pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-auto max-h-80 text-xs"> | |
| <code className="font-mono whitespace-pre text-text-main">{previewYaml}</code> | |
| </pre> | |
| </div> | |
| )} | |
| <p className="text-xs text-text-muted -mt-2"> | |
| Saves the selected models for each role into <code>~/.hermes/config.yaml</code>. | |
| </p> | |
| </div> | |
| )} | |
| <ModelSelectModal | |
| isOpen={!!modalRole} | |
| onClose={() => 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} | |
| /> | |
| </Card> | |
| ); | |
| } | |