Files
legal-ai/web-ui/src/components/operations/agent-adapters-panel.tsx
Chaim 471934cc2c
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
feat(operations): הוספת codex_local לסרגל-חירום + סקריפט A/B-benchmark
- adapter_profiles.py: codex_local.default_model = gpt-5.5 (עדכון מ-gpt-5.3-codex)
- agent-adapters-panel.tsx: כפתור "העבר הכל ל-Codex " בסרגל-החירום (ב-G/Codex fallback)
- operations.ts: הוספת codex_local לדוקומנטציה של useAdapterMigrate
- ab_halacha_codex.py: סקריפט A/B חדש — חילוץ הלכות דרך codex exec/gpt-5.5 (non-destructive benchmark)
- SCRIPTS.md: תיעוד ab_halacha_codex.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:45:14 +00:00

324 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* מתאמי-סוכנים (Adapter) — /operations panel.
*
* Migrate any committee agent between run-engines (Claude / Gemini / DeepSeek / Codex)
* in BOTH companies at once, with a preflight that prevents the silent crash
* (frontmatter `---` breaks the gemini/deepseek CLI; model must match provider;
* gemini excludeTools is global). Built to the Claude Design mockup
* `02d-operations-adapters.html`. The actual migration runs host-side
* (scripts/migrate_agent_adapter.py) via the court-fetch bridge — see
* useMigrateAdapter / POST /api/operations/agents/migrate-adapter.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
usePaperclipAgents,
type AgentPair,
} from "@/lib/api/paperclip-agents";
import {
useMigrateAdapter,
type MigrateAdapterRequest,
type MigrateAdapterResult,
} from "@/lib/api/operations";
const ADAPTERS = [
{ value: "claude_local", label: "Claude", cls: "bg-info-bg text-info border-info/40" },
{ value: "gemini_local", label: "Gemini", cls: "bg-warn-bg text-warn border-warn/40" },
{ value: "deepseek_local", label: "DeepSeek", cls: "bg-gold-wash text-gold-deep border-gold/40" },
{ value: "codex_local", label: "Codex", cls: "bg-success-bg text-success border-success/40" },
] as const;
// Bilingual role subtitle under the agent name (mockup 02d) — display-only,
// keyed by the agent's Hebrew name. Falls back to none for unmapped agents.
const ROLE_SUBTITLE: Record<string, string> = {
"עוזר משפטי": "CEO · מתזמר",
"מנתח משפטי": "analyst",
"כותב החלטה": "writer",
"מנהל ידע": "אוצֵר-הלכות",
"בודק איכות": "qa",
"הגהת מסמכים": "proofreader",
"שטן מליץ": "red-team (Gemini)",
};
function adapterCls(a: string | null | undefined): string {
return ADAPTERS.find((x) => x.value === a)?.cls ?? "bg-rule-soft text-ink-muted border-rule";
}
function adapterLabel(a: string | null | undefined): string {
return ADAPTERS.find((x) => x.value === a)?.label ?? a ?? "—";
}
/** A pending confirmation: the apply/revert to run, plus its preflight result. */
type PendingDialog = {
title: string;
body: MigrateAdapterRequest; // action is "apply" or "revert"
preflight: MigrateAdapterResult | null;
loadingPreflight: boolean;
relax: boolean;
showRelax: boolean;
};
export function AgentAdaptersPanel() {
const { data, isLoading } = usePaperclipAgents();
const migrate = useMigrateAdapter();
const [targets, setTargets] = useState<Record<string, string>>({});
const [dlg, setDlg] = useState<PendingDialog | null>(null);
const busy = migrate.isPending;
const currentOf = (p: AgentPair) =>
p.master?.adapter_type ?? p.mirror?.adapter_type ?? null;
const modelOf = (p: AgentPair) => p.master?.model ?? p.mirror?.model ?? "";
const isAsymmetric = (p: AgentPair) =>
!!p.master && !!p.mirror && p.master.adapter_type !== p.mirror.adapter_type;
const targetFor = (p: AgentPair) => targets[p.name] ?? currentOf(p) ?? "claude_local";
// Run a preflight (check) and open the confirm dialog pre-loaded with its output.
async function openMigrate(agent: string, to: string, title: string) {
setDlg({ title, body: { action: "apply", agent, to, relax_tools: true },
preflight: null, loadingPreflight: true, relax: true, showRelax: false });
try {
const res = await migrate.mutateAsync({ action: "check", agent, to });
const conflict = /excludeTools|כלי-כתיבה/.test(res.stdout);
setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false,
showRelax: conflict, relax: conflict });
} catch (e) {
setDlg((d) => d && { ...d, loadingPreflight: false,
preflight: { ok: false, exit_code: -1, stdout: "", stderr: String(e) } });
}
}
function openRevert(agent: string, title: string) {
setDlg({ title, body: { action: "revert", agent }, preflight: null,
loadingPreflight: false, relax: false, showRelax: false });
}
// Confirm → run the dialog's apply/revert and toast on the script's exit code.
async function confirmDialog() {
if (!dlg) return;
const body = { ...dlg.body, relax_tools: dlg.relax };
try {
const res = await migrate.mutateAsync(body);
if (res.exit_code === 0) {
toast.success(dlg.body.action === "revert" ? "הוחזר ל-Claude" : "המעבר הושלם בשתי החברות");
} else {
toast.error("הפעולה נכשלה — ראה פלט");
// keep dialog open so the operator sees the failure output
setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false });
return;
}
} catch (e) {
toast.error(`שגיאה: ${String(e)}`);
return;
}
setDlg(null);
}
const preflightBlocked = !!dlg?.preflight && dlg.body.action === "apply" && dlg.preflight.exit_code !== 0;
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<p className="text-ink-muted text-xs mb-4">
העברת כל סוכן בין מנועי-ההרצה (Claude · Gemini · DeepSeek · Codex), בשתי החברות יחד. כל מעבר
עובר preflight שמונע קריסה. מעבר ל-Gemini/DeepSeek = fallback מופחת-איכות להחזיר ל-Claude
כשטוקני-Claude חוזרים.
</p>
{/* Emergency fallback bar */}
<div className="rounded-md border border-gold/40 bg-gold-wash px-4 py-3 mb-4 flex items-center gap-4 flex-wrap">
<div className="min-w-0">
<div className="text-[0.85rem] text-navy font-semibold">מצב-חירום · טוקני-Claude</div>
<div className="text-[0.7rem] text-ink-muted">
כשנגמרים הטוקנים להפיל את כל הצוות ל-Gemini או ל-Codex בלחיצה, ולהחזיר כשחוזרים.
</div>
</div>
<div className="flex items-center gap-2 ms-auto shrink-0">
<Button size="sm" variant="outline" disabled={busy}
onClick={() => openRevert("all", "החזרת כל הסוכנים ל-Claude (מצב-מקור)")}>
החזר הכל ל-Claude
</Button>
<Button size="sm" variant="default" disabled={busy}
onClick={() => openMigrate("all", "gemini_local", "העברת כל הסוכנים ל-Gemini ⚡")}>
העבר הכל ל-Gemini
</Button>
<Button size="sm" variant="default" disabled={busy}
className="bg-success text-white hover:bg-success/90"
onClick={() => openMigrate("all", "codex_local", "העברת כל הסוכנים ל-Codex (gpt-5.5) ⚡")}>
העבר הכל ל-Codex
</Button>
</div>
</div>
{isLoading || !data ? (
<Skeleton className="h-40 w-full" />
) : (
<div className="overflow-x-auto rounded-md border border-rule-soft">
<Table>
<TableHeader>
<TableRow className="bg-parchment hover:bg-parchment border-rule">
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted">תפקיד</TableHead>
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted">מתאם נוכחי</TableHead>
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted">מודל</TableHead>
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted">העבר ל־</TableHead>
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted" aria-label="פעולה" />
<TableHead className="text-start text-[0.72rem] font-medium text-ink-muted">מצב</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.pairs.map((p) => {
const cur = currentOf(p);
const tgt = targetFor(p);
const migrated = cur !== "claude_local" && cur !== null;
const asym = isAsymmetric(p);
const subtitle = ROLE_SUBTITLE[p.name];
return (
<TableRow key={p.name} className={`border-rule-soft ${asym ? "bg-danger-bg/25" : ""}`}>
<TableCell className="py-2.5 align-middle">
<div className="text-[0.85rem] text-navy font-semibold leading-tight">{p.name}</div>
{subtitle ? (
<div className="text-[0.68rem] text-ink-muted leading-tight">{subtitle}</div>
) : null}
</TableCell>
<TableCell className="py-2.5 align-middle">
<Badge variant="outline" className={`font-normal ${adapterCls(cur)}`}>
{adapterLabel(cur)}
</Badge>
</TableCell>
<TableCell className="py-2.5 align-middle">
<span className="text-[0.68rem] text-ink-soft font-mono" dir="ltr">
{modelOf(p) || "(default)"}
</span>
</TableCell>
<TableCell className="py-2.5 align-middle">
<Select value={tgt} onValueChange={(v) => setTargets((t) => ({ ...t, [p.name]: v }))}>
<SelectTrigger size="sm" className="w-[8.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ADAPTERS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-2.5 align-middle">
<div className="flex items-center gap-1.5">
<Button size="xs" variant="default" disabled={busy || tgt === cur}
onClick={() => openMigrate(p.name, tgt, `העברת "${p.name}" → ${adapterLabel(tgt)}`)}>
העבר
</Button>
{migrated ? (
<Button size="xs" variant="ghost" disabled={busy}
onClick={() => openRevert(p.name, `החזרת "${p.name}" למצב-מקור`)}>
החזר
</Button>
) : null}
</div>
</TableCell>
<TableCell className="py-2.5 align-middle">
{asym ? (
<Badge variant="outline" className="font-normal bg-danger-bg text-danger border-danger/40 whitespace-nowrap">
א-סימטרי
</Badge>
) : migrated ? (
<Badge variant="outline" className="font-normal bg-warn-bg text-warn border-warn/40 whitespace-nowrap">
מועבר · fallback
</Badge>
) : (
<Badge variant="outline" className="font-normal bg-success-bg text-success border-success/40">
תקין
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
{/* Confirm dialog: preflight output + apply/revert */}
<Dialog open={!!dlg} onOpenChange={(o) => !o && setDlg(null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dlg?.title}</DialogTitle>
<DialogDescription>
{dlg?.body.action === "revert"
? "שחזור מדויק למתאם ולמודל שהיו לפני המעבר, בשתי החברות."
: "המעבר חל על שתי החברות (CMP + CMPA). Gemini/DeepSeek/Codex = fallback מופחת-איכות."}
</DialogDescription>
</DialogHeader>
{dlg?.body.action === "apply" ? (
<div className="space-y-3">
<div className="text-[0.72rem] text-ink-muted">preflight:</div>
{dlg.loadingPreflight ? (
<Skeleton className="h-24 w-full" />
) : (
<pre dir="ltr"
className={`text-[0.7rem] font-mono whitespace-pre-wrap max-h-56 overflow-auto rounded-md border p-2 ${
preflightBlocked ? "border-danger/40 bg-danger-bg" : "border-rule-soft bg-parchment"
}`}>
{(dlg.preflight?.stdout || "") + (dlg.preflight?.stderr || "") || "—"}
</pre>
)}
{dlg.showRelax ? (
<div className="flex items-center gap-2">
<Switch id="relax" checked={dlg.relax}
onCheckedChange={(c) => setDlg((d) => d && { ...d, relax: c })} />
<Label htmlFor="relax" className="text-[0.75rem] text-ink-soft">
שחרר כלי-כתיבה חסומים (excludeTools גלובלי לכל סוכני-Gemini)
</Label>
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDlg(null)} disabled={busy}>
ביטול
</Button>
<Button size="sm" disabled={busy || dlg?.loadingPreflight || preflightBlocked}
onClick={confirmDialog}>
{dlg?.body.action === "revert" ? "החזר" : preflightBlocked ? "preflight חוסם" : "אשר העברה"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}