fiemwl / src /app /(dashboard) /dashboard /cli-code /components /HermesAgentToolCard.tsx
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
20.6 kB
"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<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`;
}
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<string, any> = {};
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<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>
);
}