feat(settings): add Agents tab — read-only Paperclip agent config view
Task #29: surfaces all 14 agents (7 roles × 2 companies) in /settings as master+mirror pairs with drift detection. Replaces ad-hoc psql + script inspection with a single dashboard. Backend: GET /api/admin/paperclip-agents — fetches via Paperclip API (not direct DB), groups by name, computes drift across model/effort/ timeoutSec/maxTurnsPerRun/skills/runtime_config.heartbeat/budget/status. Frontend: new AgentsTab card-per-pair with side-by-side compare, drift highlighting, expandable details (skills list + instructions path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
367
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
367
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
usePaperclipAgents,
|
||||
type AgentPair,
|
||||
type DriftEntry,
|
||||
type PaperclipAgent,
|
||||
} from "@/lib/api/paperclip-agents";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
ceo: "CEO",
|
||||
researcher: "מחקר",
|
||||
engineer: "כתיבה",
|
||||
qa: "בקרה",
|
||||
general: "כללי",
|
||||
};
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
model: "מודל",
|
||||
effort: "effort",
|
||||
timeoutSec: "timeout (שניות)",
|
||||
maxTurnsPerRun: "max turns",
|
||||
desiredSkills: "skills",
|
||||
instructionsBundleMode: "bundle mode",
|
||||
instructionsEntryFile: "entry file",
|
||||
graceSec: "grace (שניות)",
|
||||
cooldownSec: "cooldown (שניות)",
|
||||
wakeOnDemand: "wake on demand",
|
||||
maxConcurrentRuns: "max concurrent",
|
||||
budget_monthly_cents: "תקציב חודשי",
|
||||
status: "סטטוס",
|
||||
};
|
||||
|
||||
function formatCents(cents: number | null): string {
|
||||
if (cents == null) return "—";
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function StatusBadge({ agent }: { agent: PaperclipAgent }) {
|
||||
const status = agent.status ?? "unknown";
|
||||
if (status === "paused" || status === "terminated") {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||
<PauseCircle className="w-3 h-3 me-1" />
|
||||
{status === "paused" ? "מושהה" : "סיים"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||
<PlayCircle className="w-3 h-3 me-1" />
|
||||
פעיל
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
master,
|
||||
mirror,
|
||||
drifted,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
master: React.ReactNode;
|
||||
mirror: React.ReactNode;
|
||||
drifted: boolean;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`;
|
||||
const cellCls = (val: React.ReactNode) =>
|
||||
`${cellBase} px-2 py-1 rounded ${
|
||||
drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink"
|
||||
} ${val == null || val === "—" ? "text-ink-light" : ""}`;
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
|
||||
<div className="text-[0.75rem] text-ink-muted">{label}</div>
|
||||
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
|
||||
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairCard({ pair }: { pair: AgentPair }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const driftFields = new Set(pair.drift.map((d) => d.field));
|
||||
const driftCount = pair.drift.length;
|
||||
const pairMissing = driftFields.has("_pair_missing");
|
||||
const a = pair.master ?? pair.mirror;
|
||||
if (!a) return null;
|
||||
|
||||
const fieldVal = (
|
||||
side: "master" | "mirror",
|
||||
key: keyof PaperclipAgent,
|
||||
): React.ReactNode => {
|
||||
const agent = pair[side];
|
||||
if (!agent) return <span className="text-ink-light">—</span>;
|
||||
const v = agent[key];
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "boolean") return v ? "✓" : "✗";
|
||||
if (Array.isArray(v)) return `${v.length}`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
const skillsList = (agent: PaperclipAgent | null) =>
|
||||
agent?.desiredSkills?.length ? agent.desiredSkills : [];
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
|
||||
</Badge>
|
||||
{pair.master && <StatusBadge agent={pair.master} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pairMissing ? (
|
||||
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
|
||||
<AlertCircle className="w-3 h-3 me-1" />
|
||||
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
|
||||
</Badge>
|
||||
) : driftCount > 0 ? (
|
||||
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||
<AlertCircle className="w-3 h-3 me-1" />
|
||||
{driftCount} פערים
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||
מסונכרן
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
|
||||
<div></div>
|
||||
<div>CMP (1xxx)</div>
|
||||
<div>CMPA (8xxx)</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
|
||||
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
|
||||
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
|
||||
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
|
||||
<FieldRow
|
||||
label={FIELD_LABEL.desiredSkills}
|
||||
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
|
||||
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
|
||||
drifted={driftFields.has("desiredSkills")}
|
||||
/>
|
||||
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
|
||||
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
|
||||
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
|
||||
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
|
||||
<FieldRow
|
||||
label={FIELD_LABEL.budget_monthly_cents}
|
||||
master={
|
||||
pair.master
|
||||
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
|
||||
: "—"
|
||||
}
|
||||
mirror={
|
||||
pair.mirror
|
||||
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
|
||||
: "—"
|
||||
}
|
||||
drifted={driftFields.has("budget_monthly_cents")}
|
||||
/>
|
||||
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
|
||||
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-rule">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[0.78rem] text-ink-muted"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3 me-1" />
|
||||
כיווץ
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3 me-1" />
|
||||
פרטים מלאים
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{pair.master?.updated_at && (
|
||||
<span className="text-[0.7rem] text-ink-light">
|
||||
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="pt-2 border-t border-rule space-y-3">
|
||||
{pair.drift.length > 0 && !pairMissing && (
|
||||
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
|
||||
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
|
||||
<ul className="space-y-1 text-[0.78rem]">
|
||||
{pair.drift.map((d: DriftEntry) => (
|
||||
<li key={d.field} className="flex items-center gap-2 flex-wrap">
|
||||
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
|
||||
<span className="text-ink-muted">CMP:</span>
|
||||
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
|
||||
<span className="text-ink-muted">CMPA:</span>
|
||||
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(["master", "mirror"] as const).map((side) => {
|
||||
const agent = pair[side];
|
||||
const skills = skillsList(agent);
|
||||
return (
|
||||
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
|
||||
<div className="text-[0.75rem] text-ink-muted">
|
||||
{side === "master" ? "CMP" : "CMPA"}
|
||||
</div>
|
||||
{agent ? (
|
||||
<>
|
||||
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
|
||||
id: {agent.id}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[0.72rem] text-ink-muted mb-1">
|
||||
skills ({skills.length})
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<span className="text-[0.78rem] text-ink-light">—</span>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{skills.map((s) => (
|
||||
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{agent.instructionsFilePath && (
|
||||
<div>
|
||||
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
|
||||
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
|
||||
{agent.instructionsFilePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{agent.pause_reason && (
|
||||
<div className="text-[0.78rem] text-warn">
|
||||
סיבת השהיה: {agent.pause_reason}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[0.78rem] text-ink-light">חסר</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsTab() {
|
||||
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-surface border-danger/40">
|
||||
<CardContent className="p-6 flex items-center gap-3 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>שגיאה: {error.message}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.pairs.length === 0) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||
לא נמצאו סוכנים
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalDrift = data.pairs.reduce(
|
||||
(sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length,
|
||||
0,
|
||||
);
|
||||
const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="text-[0.85rem] text-ink-muted">
|
||||
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
|
||||
{totalDrift > 0 && (
|
||||
<span className="text-warn ms-2">
|
||||
· {totalDrift} פערי סנכרון
|
||||
</span>
|
||||
)}
|
||||
{missingCount > 0 && (
|
||||
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
רענון
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
{data.pairs.map((pair) => (
|
||||
<PairCard key={pair.name} pair={pair} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Server, Wrench, Plug, Building2, Layers } from "lucide-react";
|
||||
import { Server, Wrench, Plug, Building2, Layers, Bot } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PaperclipTab } from "./_components/paperclip-tab";
|
||||
@@ -9,6 +9,7 @@ import { EnvironmentTab } from "./_components/environment-tab";
|
||||
import { ToolsTab } from "./_components/tools-tab";
|
||||
import { RegistrationsTab } from "./_components/registrations-tab";
|
||||
import { BlocksTab } from "./_components/blocks-tab";
|
||||
import { AgentsTab } from "./_components/agents-tab";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -36,6 +37,10 @@ export default function SettingsPage() {
|
||||
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
||||
Paperclip
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<Bot className="w-4 h-4" data-icon="inline-start" />
|
||||
סוכנים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="environment">
|
||||
<Server className="w-4 h-4" data-icon="inline-start" />
|
||||
סביבה
|
||||
@@ -55,6 +60,7 @@ export default function SettingsPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
||||
<TabsContent value="agents"><AgentsTab /></TabsContent>
|
||||
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
||||
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
||||
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
||||
|
||||
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Paperclip agents — read-only admin view (Task #29).
|
||||
*
|
||||
* Backend: `GET /api/admin/paperclip-agents` returns master+mirror pairs
|
||||
* (CMP / CMPA) for all 7 agent roles, with drift detection between the pair.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type PaperclipAgent = {
|
||||
id: string;
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
status: string | null;
|
||||
pause_reason: string | null;
|
||||
adapter_type: string | null;
|
||||
model: string | null;
|
||||
effort: string | null;
|
||||
timeoutSec: number | null;
|
||||
maxTurnsPerRun: number | null;
|
||||
desiredSkills: string[];
|
||||
instructionsBundleMode: string | null;
|
||||
instructionsRootPath: string | null;
|
||||
instructionsEntryFile: string | null;
|
||||
instructionsFilePath: string | null;
|
||||
graceSec: number | null;
|
||||
cooldownSec: number | null;
|
||||
wakeOnDemand: boolean | null;
|
||||
maxConcurrentRuns: number | null;
|
||||
intervalSec: number | null;
|
||||
enabled: boolean | null;
|
||||
budget_monthly_cents: number | null;
|
||||
spent_monthly_cents: number | null;
|
||||
last_heartbeat_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type DriftEntry = {
|
||||
field: string;
|
||||
master: unknown;
|
||||
mirror: unknown;
|
||||
};
|
||||
|
||||
export type AgentPair = {
|
||||
name: string;
|
||||
role: string | null;
|
||||
master: PaperclipAgent | null;
|
||||
mirror: PaperclipAgent | null;
|
||||
drift: DriftEntry[];
|
||||
};
|
||||
|
||||
export type PaperclipAgentsResponse = {
|
||||
pairs: AgentPair[];
|
||||
companies: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
slot: "master" | "mirror";
|
||||
}>;
|
||||
};
|
||||
|
||||
export function usePaperclipAgents() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "paperclip-agents"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<PaperclipAgentsResponse>("/api/admin/paperclip-agents", { signal }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user