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:
parent
5266d25727
commit
a3802a9dd6
2 changed files with 16 additions and 401 deletions
|
|
@ -73,7 +73,6 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||||
"codex_local",
|
"codex_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
"gemini_local",
|
"gemini_local",
|
||||||
"hermes_local",
|
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
type AgentPermissionUpdate,
|
type AgentPermissionUpdate,
|
||||||
} from "../api/agents";
|
} from "../api/agents";
|
||||||
import { companySkillsApi } from "../api/companySkills";
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
|
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -76,17 +75,6 @@ import {
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
import {
|
import {
|
||||||
|
|
@ -937,7 +925,7 @@ export function AgentDetail() {
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
{isPendingApproval && (
|
{isPendingApproval && (
|
||||||
<p className="text-sm text-amber-500">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1088,28 +1076,10 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
||||||
const isLive = run.status === "running" || run.status === "queued";
|
const isLive = run.status === "running" || run.status === "queued";
|
||||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||||
const StatusIcon = statusInfo.icon;
|
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 ?? "")
|
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||||
: run.error ?? "";
|
: 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
|
|
@ -1560,11 +1530,11 @@ function ConfigurationTab({
|
||||||
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
|
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
|
||||||
const taskAssignHint =
|
const taskAssignHint =
|
||||||
taskAssignSource === "ceo_role"
|
taskAssignSource === "ceo_role"
|
||||||
? `Enabled automatically for ${VOCAB.ceo} agents.`
|
? "Enabled automatically for CEO agents."
|
||||||
: taskAssignSource === "agent_creator"
|
: taskAssignSource === "agent_creator"
|
||||||
? "Enabled automatically while this agent can create new agents."
|
? "Enabled automatically while this agent can create new agents."
|
||||||
: taskAssignSource === "explicit_grant"
|
: taskAssignSource === "explicit_grant"
|
||||||
? `Enabled via explicit ${VOCAB.company.toLowerCase()} permission grant.`
|
? "Enabled via explicit company permission grant."
|
||||||
: "Disabled unless explicitly granted.";
|
: "Disabled unless explicitly granted.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1808,7 +1778,7 @@ function PromptsTab({
|
||||||
|
|
||||||
const uploadMarkdownImage = useMutation({
|
const uploadMarkdownImage = useMutation({
|
||||||
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
|
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);
|
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -2008,7 +1978,7 @@ function PromptsTab({
|
||||||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" sideOffset={4}>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2063,7 +2033,7 @@ function PromptsTab({
|
||||||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" sideOffset={4}>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2433,7 +2403,6 @@ function AgentSkillsTab({
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||||
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
|
||||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||||
const skipNextSkillAutosaveRef = useRef(true);
|
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(() => {
|
useEffect(() => {
|
||||||
setSkillDraft([]);
|
setSkillDraft([]);
|
||||||
setLastSavedSkills([]);
|
setLastSavedSkills([]);
|
||||||
|
|
@ -2651,9 +2563,9 @@ function AgentSkillsTab({
|
||||||
const unsupportedSkillMessage = useMemo(() => {
|
const unsupportedSkillMessage = useMemo(() => {
|
||||||
if (skillSnapshot?.mode !== "unsupported") return null;
|
if (skillSnapshot?.mode !== "unsupported") return null;
|
||||||
if (agent.adapterType === "openclaw_gateway") {
|
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]);
|
}, [agent.adapterType, skillSnapshot?.mode]);
|
||||||
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
|
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
|
||||||
const saveStatusLabel = syncSkills.isPending
|
const saveStatusLabel = syncSkills.isPending
|
||||||
|
|
@ -2693,289 +2605,6 @@ function AgentSkillsTab({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{isLoading ? (
|
||||||
<PageSkeleton variant="list" />
|
<PageSkeleton variant="list" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -3009,11 +2638,7 @@ function AgentSkillsTab({
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
)}
|
)}
|
||||||
{skill.readOnly && skill.originLabel && (
|
{skill.readOnly && skill.originLabel && (
|
||||||
skill.originLabel === "Hermes skill" ? (
|
<p className="mt-1 text-xs text-muted-foreground">{skill.originLabel}</p>
|
||||||
<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>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{skill.readOnly && skill.locationLabel && (
|
{skill.readOnly && skill.locationLabel && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Location: {skill.locationLabel}</p>
|
<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">
|
<section className="border-y border-border">
|
||||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
{`Required by ${VOCAB.appName}`}
|
Required by Paperclip
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{requiredSkillRows.map(renderSkillRow)}
|
{requiredSkillRows.map(renderSkillRow)}
|
||||||
|
|
@ -3107,21 +2732,12 @@ function AgentSkillsTab({
|
||||||
|
|
||||||
{unmanagedSkillRows.length > 0 && (
|
{unmanagedSkillRows.length > 0 && (
|
||||||
<section className="border-y border-border">
|
<section className="border-y border-border">
|
||||||
<div
|
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||||
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); } }}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
{agent.adapterType === "hermes_local"
|
User-installed skills, not managed by Paperclip
|
||||||
? `(${unmanagedSkillRows.length}) Hermes native skills & user-installed skills`
|
|
||||||
: `(${unmanagedSkillRows.length}) User-installed skills, not managed by ${VOCAB.appName}`}
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
{unmanagedSkillRows.map(renderSkillRow)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -3130,7 +2746,7 @@ function AgentSkillsTab({
|
||||||
|
|
||||||
{desiredOnlyMissingSkills.length > 0 && (
|
{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="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">
|
<div className="mt-1 text-xs">
|
||||||
{desiredOnlyMissingSkills.join(", ")}
|
{desiredOnlyMissingSkills.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4404,7 +4020,7 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
|
||||||
Create API Key
|
Create API Key
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue