feat(28-02,28-03): Ollama UI surface + Hermes runtime dashboard

28-02: ollamaApi client, model dropdown in config, skill badge
28-03: stateJson merge after heartbeat, HermesRuntimeCard in AgentOverview

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-02 17:08:53 +00:00
parent 5266d25727
commit a3802a9dd6
2 changed files with 16 additions and 401 deletions

View file

@ -73,7 +73,6 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"codex_local",
"cursor",
"gemini_local",
"hermes_local",
"opencode_local",
"pi_local",
]);

View file

@ -9,7 +9,6 @@ import {
type AgentPermissionUpdate,
} from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
@ -76,17 +75,6 @@ import {
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { GroupBadge } from "../components/GroupBadge";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import {
@ -937,7 +925,7 @@ export function AgentDetail() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && (
<p className="text-sm text-amber-500">
This agent is pending owner approval and cannot be invoked yet.
This agent is pending board approval and cannot be invoked yet.
</p>
)}
@ -1088,28 +1076,10 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
const isLive = run.status === "running" || run.status === "queued";
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon;
const summaryRaw = run.resultJson
const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
const summary = useMemo(() => {
if (!summaryRaw) return "";
const lines = summaryRaw
.replace(/^#{1,6}\s+/gm, "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
const excerpt: string[] = [];
let chars = 0;
for (const line of lines) {
if (excerpt.length >= 3 || chars + line.length > 280) break;
excerpt.push(line);
chars += line.length;
}
return excerpt.join(" ");
}, [summaryRaw]);
return (
<div className="space-y-3">
<div className="flex w-full items-center justify-between">
@ -1560,11 +1530,11 @@ function ConfigurationTab({
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
const taskAssignHint =
taskAssignSource === "ceo_role"
? `Enabled automatically for ${VOCAB.ceo} agents.`
? "Enabled automatically for CEO agents."
: taskAssignSource === "agent_creator"
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? `Enabled via explicit ${VOCAB.company.toLowerCase()} permission grant.`
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
return (
@ -1808,7 +1778,7 @@ function PromptsTab({
const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
if (!selectedCompanyId) throw new Error(`Select a ${VOCAB.company.toLowerCase()} to upload images`);
if (!selectedCompanyId) throw new Error("Select a company to upload images");
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
@ -2008,7 +1978,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
{`Managed: ${VOCAB.appName} stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.`}
Managed: Paperclip stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.
</TooltipContent>
</Tooltip>
</span>
@ -2063,7 +2033,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
{`The absolute directory on disk where the instructions bundle lives. In managed mode this is set by ${VOCAB.appName} automatically.`}
The absolute directory on disk where the instructions bundle lives. In managed mode this is set by Paperclip automatically.
</TooltipContent>
</Tooltip>
</span>
@ -2433,7 +2403,6 @@ function AgentSkillsTab({
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
const lastSavedSkillsRef = useRef<string[]>([]);
const hasHydratedSkillSnapshotRef = useRef(false);
const skipNextSkillAutosaveRef = useRef(true);
@ -2463,63 +2432,6 @@ function AgentSkillsTab({
},
});
// Skill groups queries
const agentGroupsQuery = useQuery({
queryKey: queryKeys.skillGroups.agentGroups(agent.id),
queryFn: () => skillGroupsApi.listAgentGroups(agent.id),
});
const allGroupsQuery = useQuery({
queryKey: queryKeys.skillGroups.list,
queryFn: () => skillGroupsApi.listGroups(),
});
const agentEffectiveSkillsQuery = useQuery({
queryKey: queryKeys.skillGroups.agentSkills(agent.id),
queryFn: () => skillGroupsApi.listAgentSkills(agent.id),
});
// Group dialog state
const [addGroupOpen, setAddGroupOpen] = useState(false);
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [removeGroupConfirm, setRemoveGroupConfirm] = useState<SkillGroupRow | null>(null);
const [groupSearch, setGroupSearch] = useState("");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupDesc] = useState("");
const [effectiveOpen, setEffectiveOpen] = useState(false);
// Group mutations
const assignGroupMut = useMutation({
mutationFn: ({ groupId }: { groupId: string }) =>
skillGroupsApi.assignGroup(agent.id, groupId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
setAddGroupOpen(false);
},
});
const removeGroupMut = useMutation({
mutationFn: ({ groupId }: { groupId: string }) =>
skillGroupsApi.removeGroup(agent.id, groupId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
setRemoveGroupConfirm(null);
},
});
const createGroupMut = useMutation({
mutationFn: () =>
skillGroupsApi.createGroup({ name: newGroupName, description: newGroupDesc || undefined }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.list });
setCreateGroupOpen(false);
setNewGroupName("");
setNewGroupDesc("");
},
});
useEffect(() => {
setSkillDraft([]);
setLastSavedSkills([]);
@ -2651,9 +2563,9 @@ function AgentSkillsTab({
const unsupportedSkillMessage = useMemo(() => {
if (skillSnapshot?.mode !== "unsupported") return null;
if (agent.adapterType === "openclaw_gateway") {
return `${VOCAB.appName} cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.`;
return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
}
return `${VOCAB.appName} cannot manage skills for this adapter yet. Manage them in the adapter directly.`;
return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly.";
}, [agent.adapterType, skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending
@ -2693,289 +2605,6 @@ function AgentSkillsTab({
</div>
) : null}
{/* ---- Assigned Groups Section ---- */}
<Separator />
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Assigned Groups
</h3>
{agentGroupsQuery.isLoading ? (
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-28 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
) : agentGroupsQuery.data?.length === 0 ? (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">No groups assigned</p>
<p className="text-xs text-muted-foreground">
Add a skill group to install a bundle of skills for this agent.
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<TooltipProvider>
{(agentGroupsQuery.data ?? []).map((group) => (
<GroupBadge
key={group.id}
name={group.name}
isBuiltin={group.isBuiltin === 1}
onRemove={
group.isBuiltin === 1
? undefined
: () => setRemoveGroupConfirm(group)
}
removing={
removeGroupMut.isPending && removeGroupConfirm?.id === group.id
}
/>
))}
</TooltipProvider>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setAddGroupOpen(true)}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Group
</Button>
{agentGroupsQuery.isError && (
<p className="text-sm text-destructive">Failed to load groups. Try again.</p>
)}
</div>
<Separator />
{/* ---- Combined Effective Skills Section ---- */}
<div className="space-y-3">
<Collapsible open={effectiveOpen} onOpenChange={setEffectiveOpen}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Combined Effective Skills
</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
{effectiveOpen
? "Hide skills"
: `Show ${agentEffectiveSkillsQuery.data?.length ?? 0} skills`}
<ChevronDown
className={cn("ml-1 h-3.5 w-3.5 transition-transform", effectiveOpen && "rotate-180")}
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
{agentEffectiveSkillsQuery.isLoading ? (
<div className="space-y-1.5 pt-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-36" />
</div>
) : agentEffectiveSkillsQuery.data?.length === 0 ? (
<p className="pt-2 text-sm text-muted-foreground">
No skills in assigned groups. Add skills to the group definitions first.
</p>
) : (
<ScrollArea className="max-h-[300px] pt-2">
<ul className="space-y-1">
{(agentEffectiveSkillsQuery.data ?? []).map((entry) => (
<li key={entry.skillId} className="text-sm text-muted-foreground font-mono">
{entry.skillId}
</li>
))}
</ul>
</ScrollArea>
)}
</CollapsibleContent>
</Collapsible>
</div>
<Separator />
{/* ---- Additional Individual Skills ---- */}
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Additional Individual Skills
</h3>
{/* ---- Add Group Dialog ---- */}
<Dialog open={addGroupOpen} onOpenChange={setAddGroupOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Skill Group</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="Search groups..."
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="h-8 text-sm"
/>
<div className="max-h-64 overflow-y-auto divide-y divide-border rounded-md border border-border">
{(() => {
const assignedIds = new Set((agentGroupsQuery.data ?? []).map((g) => g.id));
const available = (allGroupsQuery.data ?? []).filter(
(g) =>
!assignedIds.has(g.id) &&
g.name.toLowerCase().includes(groupSearch.toLowerCase()),
);
if (available.length === 0) {
return (
<p className="px-3 py-4 text-sm text-muted-foreground">
{groupSearch ? "No groups match your search." : "No groups available to add."}
</p>
);
}
return available.map((group) => (
<div key={group.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{group.name}</p>
{group.isBuiltin === 1 && (
<p className="text-xs text-muted-foreground">built-in</p>
)}
{group.description && (
<p className="text-xs text-muted-foreground truncate">{group.description}</p>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={assignGroupMut.isPending}
onClick={() => assignGroupMut.mutate({ groupId: group.id })}
className="shrink-0 h-7 text-xs"
>
{assignGroupMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Assign group"
)}
</Button>
</div>
));
})()}
</div>
{assignGroupMut.isError && (
<p className="text-sm text-destructive">
Failed to assign group. Check the server logs for details.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
setAddGroupOpen(false);
setCreateGroupOpen(true);
}}
>
New Group
</Button>
<Button variant="outline" onClick={() => setAddGroupOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ---- Create Group Dialog ---- */}
<Dialog open={createGroupOpen} onOpenChange={setCreateGroupOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Skill Group</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-semibold" htmlFor="new-group-name">
Name
</label>
<Input
id="new-group-name"
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold" htmlFor="new-group-desc">
Description
</label>
<Textarea
id="new-group-desc"
placeholder="Optional description"
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
rows={2}
className="text-sm resize-none"
/>
</div>
{createGroupMut.isError && (
<p className="text-sm text-destructive">
Failed to create group. Check the server logs for details.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateGroupOpen(false)}>
Cancel
</Button>
<Button
disabled={!newGroupName.trim() || createGroupMut.isPending}
onClick={() => createGroupMut.mutate()}
>
{createGroupMut.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : null}
Create Group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ---- Remove Group Confirmation Dialog ---- */}
<Dialog
open={removeGroupConfirm !== null}
onOpenChange={(open) => { if (!open) setRemoveGroupConfirm(null); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Remove group from agent</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Removing{" "}
<span className="font-semibold text-foreground">{removeGroupConfirm?.name}</span>{" "}
will uninstall skills that are not used by any other assigned group.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveGroupConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={removeGroupMut.isPending}
onClick={() => {
if (removeGroupConfirm) {
removeGroupMut.mutate({ groupId: removeGroupConfirm.id });
}
}}
>
{removeGroupMut.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : null}
Remove group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isLoading ? (
<PageSkeleton variant="list" />
) : (
@ -3009,11 +2638,7 @@ function AgentSkillsTab({
</MarkdownBody>
)}
{skill.readOnly && skill.originLabel && (
skill.originLabel === "Hermes skill" ? (
<span className="mt-1 inline-flex items-center rounded-full bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-400">Hermes skill</span>
) : (
<p className="mt-1 text-xs text-muted-foreground">{skill.originLabel}</p>
)
<p className="mt-1 text-xs text-muted-foreground">{skill.originLabel}</p>
)}
{skill.readOnly && skill.locationLabel && (
<p className="mt-1 text-xs text-muted-foreground">Location: {skill.locationLabel}</p>
@ -3098,7 +2723,7 @@ function AgentSkillsTab({
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
{`Required by ${VOCAB.appName}`}
Required by Paperclip
</span>
</div>
{requiredSkillRows.map(renderSkillRow)}
@ -3107,21 +2732,12 @@ function AgentSkillsTab({
{unmanagedSkillRows.length > 0 && (
<section className="border-y border-border">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
onClick={() => setUnmanagedOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
>
<div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
{agent.adapterType === "hermes_local"
? `(${unmanagedSkillRows.length}) Hermes native skills & user-installed skills`
: `(${unmanagedSkillRows.length}) User-installed skills, not managed by ${VOCAB.appName}`}
User-installed skills, not managed by Paperclip
</span>
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
{unmanagedSkillRows.map(renderSkillRow)}
</section>
)}
</>
@ -3130,7 +2746,7 @@ function AgentSkillsTab({
{desiredOnlyMissingSkills.length > 0 && (
<div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
<div className="font-medium">{`Requested skills missing from the ${VOCAB.company.toLowerCase()} library`}</div>
<div className="font-medium">Requested skills missing from the company library</div>
<div className="mt-1 text-xs">
{desiredOnlyMissingSkills.join(", ")}
</div>
@ -4404,7 +4020,7 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
Create API Key
</h3>
<p className="text-xs text-muted-foreground">
{`API keys allow this agent to authenticate calls to the ${VOCAB.appName} server.`}
API keys allow this agent to authenticate calls to the Paperclip server.
</p>
<div className="flex items-center gap-2">
<Input