10 Commits

Author SHA1 Message Date
e90faa9ba4 feat(settings): add Blocks tab — 12-block decision schema reference
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
Read-only display of BLOCK_CONFIG from block_writer.py with CREAC role
and JWM functional-purpose annotations per block (sourced from
docs/block-schema.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 07:58:04 +00:00
ae35934383 feat(settings): wire frontend to Coolify SoT response shape
- McpEnvVar: infisical_value → coolify_value + has_duplicates
- McpEnvResponse: drop Infisical metadata fields
- EnvVarRow: 'Coolify:' label, 'ערוך ב-Coolify' external link
- DriftBadge: infisicalAvailable → coolifyAvailable
- EnvironmentTab: Coolify app badge, duplicates count
2026-05-04 07:53:27 +00:00
d1e12619d4 refactor(settings): pivot to Coolify env API as source of truth
Investigation showed legal-ai container has no INFISICAL_TOKEN and there
is no /legal-ai folder in Infisical — all env vars are stored in Coolify
and injected into os.environ at container start.

- Replace _read_infisical_values with _read_coolify_envs
- New: _coolify_authoritative_value picks among Coolify duplicates
- PATCH writes via Coolify API (upsert by key)
- Drift = Coolify-stored vs container-runtime (common: Coolify edited
  without redeploy)
- Response field renamed: infisical_value → coolify_value
- New 'has_duplicates' flag per row when Coolify has multiple entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 07:50:02 +00:00
1cb832473c fix(settings): unknown drift state when Infisical unavailable + RTL drawer
- DriftBadge shows 'Unknown' (not 'Synced') when infisical_available=false
- Plumb infisicalAvailable from EnvironmentTab through EnvVarRow → DriftBadge
- Add dir='rtl' to ToolDetailDrawer SheetContent for Hebrew descriptions
2026-05-04 07:01:42 +00:00
89ce6c79d7 feat(settings): implement Registrations tab
Replaces stub RegistrationsTab with a full read-only view grouped by client.
Handles all 4 states: loading skeleton, fetch error, host_path_unavailable,
empty list, and populated data with per-registration detail rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:50:12 +00:00
7e3c912899 feat(settings): implement Tools tab with detail drawer
Replaces stub ToolsTab with a grouped-by-module grid of clickable tool cards.
Adds ToolDetailDrawer (Sheet) showing name, description, module, source_location,
and params_schema for the selected tool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:50:08 +00:00
f418686724 feat(settings): implement Environment tab with edit + drift detection
Add drift-badge, env-var-editor, env-var-row components and replace the
environment-tab stub; install shadcn Switch which was missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:47:40 +00:00
8289b4d643 refactor(settings): split into tabs (paperclip + 3 stubs)
Extracts Paperclip companies + tag-mappings UI into PaperclipTab component,
adds stub tabs for Environment / Tools / Registrations, and replaces the flat
page.tsx with a shadcn Tabs layout to make room for Tasks 8-10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:44:27 +00:00
6c129a1350 feat(settings): add MCP API hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:41:30 +00:00
320b9d3529 fix(settings): guard paperclip mcp.json type + sort registrations 2026-05-04 06:40:16 +00:00
15 changed files with 1422 additions and 360 deletions

View File

@@ -0,0 +1,128 @@
"use client";
import { Layers, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpBlocks, type McpBlock } from "@/lib/api/settings";
const GEN_TYPE_LABEL: Record<string, string> = {
"template-fill": "מילוי תבנית",
"paraphrase": "פרפרזה",
"reproduction": "שעתוק",
"guided-synthesis": "סינתזה מודרכת",
"rhetorical-construction": "בניה רטורית",
};
const GEN_TYPE_TONE: Record<string, string> = {
"template-fill": "text-ink-muted border-rule",
"paraphrase": "text-info border-info/40",
"reproduction": "text-info border-info/40",
"guided-synthesis": "text-warn border-warn/40",
"rhetorical-construction": "text-gold-deep border-gold/40",
};
function BlockRow({ block }: { block: McpBlock }) {
const isLLM = block.model !== "script";
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-md bg-navy/5 border border-navy/20 flex items-center justify-center">
<span className="text-navy text-sm font-semibold tabular-nums">
{block.index}
</span>
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-navy font-medium">{block.title}</h3>
<code dir="ltr" className="font-mono text-[0.72rem] text-ink-muted">
{block.id}
</code>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
className={`text-[0.7rem] ${GEN_TYPE_TONE[block.gen_type] ?? ""}`}
>
{GEN_TYPE_LABEL[block.gen_type] ?? block.gen_type}
</Badge>
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
{block.model}
</Badge>
{isLLM && block.temperature !== null && (
<Badge variant="outline" className="text-[0.7rem]">
temp&nbsp;<span className="tabular-nums">{block.temperature}</span>
</Badge>
)}
{block.max_tokens !== null && (
<Badge variant="outline" className="text-[0.7rem]">
max&nbsp;<span className="tabular-nums">{block.max_tokens}</span>
</Badge>
)}
</div>
{(block.creac_role || block.jwm_purpose) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[0.78rem] text-ink-muted pt-1">
{block.creac_role && (
<div>
<span className="text-[0.7rem] uppercase tracking-wide me-1">
CREAC:
</span>
<span dir="ltr">{block.creac_role}</span>
</div>
)}
{block.jwm_purpose && (
<div>
<span className="text-[0.7rem] uppercase tracking-wide me-1">
JWM:
</span>
<span dir="ltr">{block.jwm_purpose}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export function BlocksTab() {
const { data, isPending, error } = useMcpBlocks();
if (isPending) return <Skeleton className="h-96 w-full" />;
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 (!data) return null;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<div className="flex items-center gap-2 mb-4 text-ink-muted text-sm">
<Layers className="w-4 h-4" />
<span>
ארכיטקטורת 12 הבלוקים של החלטת ועדת ערר. מקור הסכימה:{" "}
<code dir="ltr" className="font-mono text-[0.78rem]">
docs/block-schema.md
</code>
.
</span>
</div>
<div className="space-y-3">
{data.blocks.map((b) => (
<BlockRow key={b.id} block={b} />
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { AlertTriangle, CheckCircle2, HelpCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
type Props = {
drift: boolean;
// When false, Coolify was unreachable: drift state is unknown, not "synced".
coolifyAvailable?: boolean;
};
export function DriftBadge({ drift, coolifyAvailable = true }: Props) {
if (!coolifyAvailable) {
return (
<Badge
variant="outline"
className="text-ink-muted border-rule gap-1"
title="Coolify לא זמין — מצב ה-drift לא ידוע"
>
<HelpCircle className="w-3 h-3" />
Unknown
</Badge>
);
}
if (drift) {
return (
<Badge variant="outline" className="text-warn border-warn/40 gap-1">
<AlertTriangle className="w-3 h-3" />
Drift
</Badge>
);
}
return (
<Badge variant="outline" className="text-success border-success/40 gap-1">
<CheckCircle2 className="w-3 h-3" />
Synced
</Badge>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { McpEnvVar } from "@/lib/api/settings";
type Props = {
spec: McpEnvVar;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
};
export function EnvVarEditor({ spec, value, onChange, disabled }: Props) {
if (spec.type === "bool") {
const checked = value === "true";
return (
<Switch
checked={checked}
onCheckedChange={(c) => onChange(c ? "true" : "false")}
disabled={disabled}
/>
);
}
if (spec.enum_values && spec.enum_values.length > 0) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{spec.enum_values.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (spec.type === "int" || spec.type === "float") {
return (
<Input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
min={spec.min ?? undefined}
max={spec.max ?? undefined}
step={spec.type === "float" ? "0.01" : "1"}
disabled={disabled}
className="w-[160px] text-start"
dir="ltr"
/>
);
}
return (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-[260px] text-start"
dir="ltr"
/>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { ExternalLink, Save, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { McpEnvVar } from "@/lib/api/settings";
import { useUpdateMcpEnv } from "@/lib/api/settings";
import { toast } from "sonner";
import { DriftBadge } from "./drift-badge";
import { EnvVarEditor } from "./env-var-editor";
type Props = {
spec: McpEnvVar;
coolifyAppUuid: string;
coolifyAvailable: boolean;
onPendingRedeploy: () => void;
};
export function EnvVarRow({
spec,
coolifyAppUuid,
coolifyAvailable,
onPendingRedeploy,
}: Props) {
const [draft, setDraft] = useState<string>(spec.coolify_value ?? "");
const update = useUpdateMcpEnv();
const dirty = draft !== (spec.coolify_value ?? "");
function handleSave() {
update.mutate(
{ key: spec.key, value: draft },
{
onSuccess: (res) => {
toast.success(res.message);
onPendingRedeploy();
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
const coolifyEnvUrl =
`https://coolify.nautilus.marcusgroup.org/project/applications/${coolifyAppUuid}/environment-variables`;
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-medium text-navy" dir="ltr">
{spec.key}
</code>
<Badge variant="outline" className="text-[0.7rem]">
{spec.type}
</Badge>
{spec.is_secret && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
<Lock className="w-3 h-3" />
secret
</Badge>
)}
<DriftBadge drift={spec.drift} coolifyAvailable={coolifyAvailable} />
{spec.has_duplicates && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40">
duplicates
</Badge>
)}
</div>
<p className="text-sm text-ink-muted mt-1">{spec.description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Coolify:</span>
{spec.is_editable ? (
<EnvVarEditor
spec={spec}
value={draft}
onChange={setDraft}
disabled={update.isPending}
/>
) : (
<span className="font-mono text-ink" dir="ltr">
{spec.coolify_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
<span className="font-mono text-ink" dir="ltr">
{spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-3">
{!spec.is_editable && (
<a
href={coolifyEnvUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[0.78rem] text-gold-deep hover:underline flex items-center gap-1"
>
ערוך ב-Coolify
<ExternalLink className="w-3 h-3" />
</a>
)}
{spec.is_editable && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || update.isPending}
>
<Save className="w-3.5 h-3.5" data-icon="inline-start" />
{update.isPending ? "שומר..." : "שמור"}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useMemo } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
useMcpEnv,
useMcpRedeploy,
type McpEnvVar,
type EnvCategory,
} from "@/lib/api/settings";
import { toast } from "sonner";
import { EnvVarRow } from "./env-var-row";
const CATEGORY_LABELS: Record<EnvCategory, string> = {
multimodal: "Multimodal",
rerank: "Rerank",
halacha: "Halacha",
general: "כללי",
credentials: "אישורים",
connection: "חיבורים",
};
const CATEGORY_ORDER: EnvCategory[] = [
"multimodal", "rerank", "halacha", "general", "credentials", "connection",
];
export function EnvironmentTab() {
const { data, isPending, error } = useMcpEnv();
const redeploy = useMcpRedeploy();
const [pendingRedeploy, setPendingRedeploy] = useState(false);
const grouped = useMemo(() => {
if (!data?.vars) return new Map<EnvCategory, McpEnvVar[]>();
const m = new Map<EnvCategory, McpEnvVar[]>();
for (const v of data.vars) {
const arr = m.get(v.category) ?? [];
arr.push(v);
m.set(v.category, arr);
}
return m;
}, [data]);
function handleRedeploy() {
redeploy.mutate(undefined, {
onSuccess: (res) => {
toast.success(res.message);
setPendingRedeploy(false);
},
onError: (err) => toast.error(`Redeploy נכשל: ${err.message}`),
});
}
if (isPending) return <Skeleton className="h-96 w-full" />;
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>שגיאה בטעינת env vars: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
const coolifyAvailable = data.errors.length === 0;
const driftCount = data.vars.filter((v) => v.drift).length;
const duplicatesCount = data.vars.filter((v) => v.has_duplicates).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap text-sm">
<Badge variant="outline">
Coolify app: <code dir="ltr" className="ms-1">{data.coolify_app_uuid.slice(0, 8)}</code>
</Badge>
{driftCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{driftCount} drift
</Badge>
)}
{duplicatesCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{duplicatesCount} duplicates
</Badge>
)}
{data.errors.length > 0 && (
<Badge variant="outline" className="text-danger border-danger/40">
{data.errors.join(", ")}
</Badge>
)}
</div>
<Button
onClick={handleRedeploy}
disabled={redeploy.isPending}
variant={pendingRedeploy ? "default" : "outline"}
size="sm"
>
<RefreshCw className={redeploy.isPending ? "w-3.5 h-3.5 animate-spin" : "w-3.5 h-3.5"} data-icon="inline-start" />
{redeploy.isPending ? "Redeploying..." : "Redeploy now"}
</Button>
</CardContent>
</Card>
{CATEGORY_ORDER.map((cat) => {
const vars = grouped.get(cat);
if (!vars || vars.length === 0) return null;
return (
<Card key={cat} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{CATEGORY_LABELS[cat]}
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{vars.length}
</Badge>
</h2>
<div className="space-y-3">
{vars.map((v) => (
<EnvVarRow
key={v.key}
spec={v}
coolifyAppUuid={data.coolify_app_uuid}
coolifyAvailable={coolifyAvailable}
onPendingRedeploy={() => setPendingRedeploy(true)}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useTagMappings,
usePaperclipCompanies,
useAddTagMapping,
useDeleteTagMapping,
} from "@/lib/api/settings";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner";
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
export function PaperclipTab() {
const { data: mappings, isPending: loadingMappings } = useTagMappings();
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
const addMapping = useAddTagMapping();
const deleteMapping = useDeleteTagMapping();
const [tag, setTag] = useState("");
const [tagLabel, setTagLabel] = useState("");
const [companyId, setCompanyId] = useState("");
function handleTagInput(value: string) {
setTag(value);
const match = TAG_SUGGESTIONS.find((s) => s.value === value);
if (match) setTagLabel(match.label);
}
function handleAdd() {
if (!tag || !companyId) {
toast.error("יש לבחור תגית וחברה");
return;
}
const company = companies?.find((c) => c.id === companyId);
addMapping.mutate(
{
tag,
tag_label: tagLabel,
company_id: companyId,
company_name: company?.name ?? "",
},
{
onSuccess: () => {
toast.success("מיפוי נוסף בהצלחה");
setTag("");
setTagLabel("");
setCompanyId("");
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
function handleDelete(id: string, tag: string) {
deleteMapping.mutate(id, {
onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
onError: (err) => toast.error(`שגיאה: ${err.message}`),
});
}
return (
<div className="space-y-6">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
חברות ב-Paperclip
</h2>
{loadingCompanies ? (
<Skeleton className="h-12 w-full" />
) : !companies?.length ? (
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
) : (
<div className="flex flex-wrap gap-3">
{companies.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
>
<span className="text-sm font-medium text-ink">{c.name}</span>
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{c.prefix}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
<Tags className="w-4 h-4" />
מיפוי תגיות
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{mappings?.length ?? 0}
</Badge>
</h2>
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
<div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted">תגית</label>
<Input
list="tag-suggestions"
value={tag}
onChange={(e) => handleTagInput(e.target.value)}
placeholder="סוג ערר או תגית חופשית"
className="w-[220px]"
/>
<datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</datalist>
</div>
<div className="flex flex-col gap-1.5 min-w-[140px]">
<label className="text-[0.72rem] text-ink-muted">תווית</label>
<Input
value={tagLabel}
onChange={(e) => setTagLabel(e.target.value)}
placeholder="שם לתצוגה"
className="w-[160px]"
/>
</div>
<div className="flex flex-col gap-1.5 min-w-[200px]">
<label className="text-[0.72rem] text-ink-muted">
חברה ב-Paperclip
</label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="בחר חברה" />
</SelectTrigger>
<SelectContent>
{companies?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} ({c.prefix})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
disabled={addMapping.isPending || !tag || !companyId}
size="default"
>
<Plus className="w-4 h-4" data-icon="inline-start" />
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
</Button>
</div>
{loadingMappings ? (
<Skeleton className="h-32 w-full" />
) : !mappings?.length ? (
<p className="text-ink-muted text-sm">
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
לפרויקט בחברה הנכונה.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
<th className="text-start py-2 px-3 font-medium">Tag</th>
<th className="text-start py-2 px-3 font-medium">Label</th>
<th className="text-start py-2 px-3 font-medium">Company</th>
<th className="py-2 px-3 w-12" />
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr
key={m.id}
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
>
<td className="py-2.5 px-3">
<Badge variant="outline" className="text-[0.75rem] font-mono">
{m.tag}
</Badge>
</td>
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
<td className="py-2.5 px-3">
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(m.id, m.tag)}
disabled={deleteMapping.isPending}
title="מחק מיפוי"
>
<Trash2 className="w-3.5 h-3.5 text-danger" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Plug, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpRegistrations } from "@/lib/api/settings";
export function RegistrationsTab() {
const { data, isPending, error } = useMcpRegistrations();
if (isPending) return <Skeleton className="h-64 w-full" />;
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 (!data) return null;
if (data.error === "host_path_unavailable") {
return (
<Card className="bg-surface border-warn/40">
<CardContent className="p-6">
<div className="flex items-center gap-3 text-warn mb-2">
<AlertCircle className="w-5 h-5" />
<span className="font-medium">תיקיית /host לא זמינה בקונטיינר</span>
</div>
<p className="text-sm text-ink-muted mb-2">
כדי להציג רישומי MCP, יש להוסיף volume mounts ב-Coolify.
ראה runbook ב-
<code dir="ltr" className="mx-1">
docs/runbooks/coolify-mcp-settings-volumes.md
</code>
</p>
{data.message && (
<p className="text-sm text-ink-muted">{data.message}</p>
)}
</CardContent>
</Card>
);
}
if (!data.registrations.length) {
return (
<Card className="bg-surface border-rule">
<CardContent className="p-6 text-ink-muted text-sm">
לא נמצאו רישומי MCP.
</CardContent>
</Card>
);
}
// Group by client
const groups = new Map<string, typeof data.registrations>();
for (const r of data.registrations) {
const arr = groups.get(r.client) ?? [];
arr.push(r);
groups.set(r.client, arr);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Plug className="w-4 h-4" />
סה&quot;כ {data.registrations.length} רישומים
</div>
{[...groups.entries()].map(([client, regs]) => (
<Card key={client} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{client}
<Badge variant="outline" className="text-[0.7rem]">
{regs.length}
</Badge>
</h2>
<div className="space-y-3">
{regs.map((r, i) => (
<div
key={`${r.server_name}-${i}`}
className="rounded-md border border-rule bg-rule-soft/20 p-4 space-y-2 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<code dir="ltr" className="font-mono font-medium text-navy">
{r.server_name}
</code>
<Badge variant="outline" className="text-[0.7rem]" dir="ltr">
{r.transport}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-[100px_1fr] gap-x-3 gap-y-1.5 text-[0.82rem]">
<span className="text-ink-muted">command:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.command || "—"}
</code>
<span className="text-ink-muted">args:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.args.length ? JSON.stringify(r.args) : "[]"}
</code>
<span className="text-ink-muted">cwd:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.cwd || "—"}
</code>
<span className="text-ink-muted">env keys:</span>
<div className="flex flex-wrap gap-1">
{r.env_keys.length === 0 ? (
<span className="text-ink-muted"></span>
) : (
r.env_keys.map((k) => (
<Badge
key={k}
variant="outline"
className="text-[0.7rem] font-mono"
dir="ltr"
>
{k}
</Badge>
))
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import type { McpTool } from "@/lib/api/settings";
type Props = {
tool: McpTool | null;
open: boolean;
onOpenChange: (o: boolean) => void;
};
export function ToolDetailDrawer({ tool, open, onOpenChange }: Props) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent dir="rtl" side="left" className="sm:max-w-xl overflow-y-auto">
{tool && (
<>
<SheetHeader>
<SheetTitle dir="ltr" className="font-mono text-navy">
{tool.name}
</SheetTitle>
<SheetDescription>{tool.description || "—"}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mt-4 px-4 pb-6">
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Module
</div>
<Badge variant="outline" className="font-mono" dir="ltr">
{tool.module}
</Badge>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Source
</div>
<code dir="ltr" className="text-xs text-ink break-all">
{tool.source_location || "—"}
</code>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Parameters Schema
</div>
<pre
dir="ltr"
className="text-xs bg-rule-soft/40 border border-rule rounded-md p-3 overflow-x-auto"
>
{JSON.stringify(tool.params_schema, null, 2)}
</pre>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useState, useMemo } from "react";
import { Wrench, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpTools, type McpTool } from "@/lib/api/settings";
import { ToolDetailDrawer } from "./tool-detail-drawer";
export function ToolsTab() {
const { data, isPending, error } = useMcpTools();
const [selected, setSelected] = useState<McpTool | null>(null);
const [open, setOpen] = useState(false);
const grouped = useMemo(() => {
if (!data?.tools) return new Map<string, McpTool[]>();
const m = new Map<string, McpTool[]>();
for (const t of data.tools) {
const mod = t.module.split(".").pop() || "other";
const arr = m.get(mod) ?? [];
arr.push(t);
m.set(mod, arr);
}
return m;
}, [data]);
if (isPending) return <Skeleton className="h-96 w-full" />;
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>שגיאה בטעינת tools: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Wrench className="w-4 h-4" />
סה&quot;כ {data.count} tools
</div>
{[...grouped.entries()].sort().map(([mod, tools]) => (
<Card key={mod} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<code dir="ltr">{mod}</code>
<Badge variant="outline" className="text-[0.7rem]">
{tools.length}
</Badge>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{tools.map((t) => (
<button
key={t.name}
onClick={() => {
setSelected(t);
setOpen(true);
}}
className="text-start rounded-md border border-rule px-3 py-2 hover:bg-rule-soft/40 transition-colors"
>
<code dir="ltr" className="font-mono text-sm text-navy">
{t.name}
</code>
{t.description && (
<p className="text-[0.78rem] text-ink-muted mt-0.5 line-clamp-2">
{t.description}
</p>
)}
</button>
))}
</div>
</CardContent>
</Card>
))}
<ToolDetailDrawer tool={selected} open={open} onOpenChange={setOpen} />
</div>
);
}

View File

@@ -1,80 +1,16 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
import { Server, Wrench, Plug, Building2, Layers } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useTagMappings,
usePaperclipCompanies,
useAddTagMapping,
useDeleteTagMapping,
} from "@/lib/api/settings";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner";
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PaperclipTab } from "./_components/paperclip-tab";
import { EnvironmentTab } from "./_components/environment-tab";
import { ToolsTab } from "./_components/tools-tab";
import { RegistrationsTab } from "./_components/registrations-tab";
import { BlocksTab } from "./_components/blocks-tab";
export default function SettingsPage() {
const { data: mappings, isPending: loadingMappings } = useTagMappings();
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
const addMapping = useAddTagMapping();
const deleteMapping = useDeleteTagMapping();
const [tag, setTag] = useState("");
const [tagLabel, setTagLabel] = useState("");
const [companyId, setCompanyId] = useState("");
function handleTagInput(value: string) {
setTag(value);
const match = TAG_SUGGESTIONS.find((s) => s.value === value);
if (match) setTagLabel(match.label);
}
function handleAdd() {
if (!tag || !companyId) {
toast.error("יש לבחור תגית וחברה");
return;
}
const company = companies?.find((c) => c.id === companyId);
addMapping.mutate(
{
tag,
tag_label: tagLabel,
company_id: companyId,
company_name: company?.name ?? "",
},
{
onSuccess: () => {
toast.success("מיפוי נוסף בהצלחה");
setTag("");
setTagLabel("");
setCompanyId("");
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
function handleDelete(id: string, tag: string) {
deleteMapping.mutate(id, {
onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
onError: (err) => toast.error(`שגיאה: ${err.message}`),
});
}
return (
<AppShell>
<section className="space-y-6">
@@ -88,164 +24,42 @@ export default function SettingsPage() {
</nav>
<h1 className="text-navy mb-0">הגדרות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
תצורת המערכת, MCP server, ו-Paperclip integration.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* Companies overview */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
חברות ב-Paperclip
</h2>
{loadingCompanies ? (
<Skeleton className="h-12 w-full" />
) : !companies?.length ? (
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
) : (
<div className="flex flex-wrap gap-3">
{companies.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
>
<span className="text-sm font-medium text-ink">{c.name}</span>
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{c.prefix}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Tabs defaultValue="paperclip" className="space-y-4">
<TabsList>
<TabsTrigger value="paperclip">
<Building2 className="w-4 h-4" data-icon="inline-start" />
Paperclip
</TabsTrigger>
<TabsTrigger value="environment">
<Server className="w-4 h-4" data-icon="inline-start" />
Environment
</TabsTrigger>
<TabsTrigger value="tools">
<Wrench className="w-4 h-4" data-icon="inline-start" />
Tools
</TabsTrigger>
<TabsTrigger value="blocks">
<Layers className="w-4 h-4" data-icon="inline-start" />
Blocks
</TabsTrigger>
<TabsTrigger value="registrations">
<Plug className="w-4 h-4" data-icon="inline-start" />
Registrations
</TabsTrigger>
</TabsList>
{/* Tag mappings */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
<Tags className="w-4 h-4" />
מיפוי תגיות
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{mappings?.length ?? 0}
</Badge>
</h2>
{/* Add form */}
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
<div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted">
תגית
</label>
<Input
list="tag-suggestions"
value={tag}
onChange={(e) => handleTagInput(e.target.value)}
placeholder="סוג ערר או תגית חופשית"
className="w-[220px]"
/>
<datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</datalist>
</div>
<div className="flex flex-col gap-1.5 min-w-[140px]">
<label className="text-[0.72rem] text-ink-muted">תווית</label>
<Input
value={tagLabel}
onChange={(e) => setTagLabel(e.target.value)}
placeholder="שם לתצוגה"
className="w-[160px]"
/>
</div>
<div className="flex flex-col gap-1.5 min-w-[200px]">
<label className="text-[0.72rem] text-ink-muted">
חברה ב-Paperclip
</label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="בחר חברה" />
</SelectTrigger>
<SelectContent>
{companies?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} ({c.prefix})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
disabled={addMapping.isPending || !tag || !companyId}
size="default"
>
<Plus className="w-4 h-4" data-icon="inline-start" />
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
</Button>
</div>
{/* Table */}
{loadingMappings ? (
<Skeleton className="h-32 w-full" />
) : !mappings?.length ? (
<p className="text-ink-muted text-sm">
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
לפרויקט בחברה הנכונה.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
<th className="text-start py-2 px-3 font-medium">Tag</th>
<th className="text-start py-2 px-3 font-medium">Label</th>
<th className="text-start py-2 px-3 font-medium">Company</th>
<th className="py-2 px-3 w-12" />
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr
key={m.id}
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
>
<td className="py-2.5 px-3">
<Badge variant="outline" className="text-[0.75rem] font-mono">
{m.tag}
</Badge>
</td>
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
<td className="py-2.5 px-3">
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(m.id, m.tag)}
disabled={deleteMapping.isPending}
title="מחק מיפוי"
>
<Trash2 className="w-3.5 h-3.5 text-danger" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
<TabsContent value="tools"><ToolsTab /></TabsContent>
<TabsContent value="blocks"><BlocksTab /></TabsContent>
<TabsContent value="registrations"><RegistrationsTab /></TabsContent>
</Tabs>
</section>
</AppShell>
);

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] rtl:group-data-[size=default]/switch:data-checked:-translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] rtl:group-data-[size=sm]/switch:data-checked:-translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 rtl:group-data-[size=default]/switch:data-unchecked:-translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 rtl:group-data-[size=sm]/switch:data-unchecked:-translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -55,3 +55,141 @@ export function useDeleteTagMapping() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
});
}
// ── MCP Settings ────────────────────────────────────────────────
export type EnvCategory =
| "multimodal"
| "rerank"
| "halacha"
| "credentials"
| "connection"
| "general";
export type EnvType = "bool" | "int" | "float" | "string";
export type McpEnvVar = {
key: string;
category: EnvCategory;
type: EnvType;
description: string;
is_secret: boolean;
is_editable: boolean;
default: unknown;
min: number | null;
max: number | null;
enum_values: string[] | null;
coolify_value: string | null;
container_value: string | null;
drift: boolean;
has_duplicates: boolean;
};
export type McpEnvResponse = {
vars: McpEnvVar[];
coolify_app_uuid: string;
errors: string[];
};
export type McpTool = {
name: string;
description: string;
params_schema: unknown;
module: string;
source_location: string;
};
export type McpRegistration = {
client: string;
server_name: string;
command: string;
args: string[];
cwd: string;
env_keys: string[];
transport: string;
};
export function useMcpEnv() {
return useQuery({
queryKey: ["settings", "mcp-env"] as const,
queryFn: ({ signal }) =>
apiRequest<McpEnvResponse>("/api/settings/mcp/env", { signal }),
staleTime: 5_000,
});
}
export function useUpdateMcpEnv() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
apiRequest<{
ok: boolean;
key: string;
saved_value: string;
requires_redeploy: boolean;
message: string;
}>(`/api/settings/mcp/env/${encodeURIComponent(key)}`, {
method: "PATCH",
body: { value },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "mcp-env"] }),
});
}
export function useMcpRedeploy() {
return useMutation({
mutationFn: () =>
apiRequest<{ ok: boolean; deployment_uuid: string | null; message: string }>(
"/api/settings/mcp/env/redeploy",
{ method: "POST" },
),
});
}
export function useMcpTools() {
return useQuery({
queryKey: ["settings", "mcp-tools"] as const,
queryFn: ({ signal }) =>
apiRequest<{ tools: McpTool[]; count: number }>("/api/settings/mcp/tools", {
signal,
}),
staleTime: 60_000,
});
}
export function useMcpRegistrations() {
return useQuery({
queryKey: ["settings", "mcp-registrations"] as const,
queryFn: ({ signal }) =>
apiRequest<{
registrations: McpRegistration[];
error: string | null;
message?: string;
}>("/api/settings/mcp/registrations", { signal }),
staleTime: 60_000,
});
}
export type McpBlock = {
id: string;
index: number;
title: string;
gen_type: string;
model: string;
temperature: number | null;
max_tokens: number | null;
creac_role: string | null;
jwm_purpose: string | null;
};
export function useMcpBlocks() {
return useQuery({
queryKey: ["settings", "mcp-blocks"] as const,
queryFn: ({ signal }) =>
apiRequest<{ blocks: McpBlock[]; count: number }>(
"/api/settings/mcp/blocks",
{ signal },
),
staleTime: 5 * 60_000, // 5 minutes — static reference data
});
}

View File

@@ -2552,6 +2552,12 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
# ── Settings: MCP Server Configuration ────────────────────────────
#
# Source of truth for legal-ai env vars is Coolify (see memory:
# reference_legal_ai_env_architecture). The container's os.environ is
# populated by Coolify at startup. We read & write through the Coolify
# API. Drift = (Coolify env value != container os.environ value), which
# means a Coolify update was made without a redeploy.
# Module-level guard: minimum interval between redeploys (60 seconds).
# Prevents accidental double-clicks or automated retry loops from queueing
@@ -2560,121 +2566,144 @@ _LAST_REDEPLOY_AT: float = 0.0
_REDEPLOY_MIN_INTERVAL_SEC: float = 60.0
def _infisical_client():
"""Build Infisical SDK client, or return None if not configured."""
token = os.environ.get("INFISICAL_TOKEN", "")
if not token:
return None
try:
from infisical_sdk import InfisicalSDKClient
return InfisicalSDKClient(token=token)
except Exception as e:
logger.warning("infisical_client_unavailable: %s", e)
return None
def _infisical_ctx():
"""Return (project_id, environment, secret_path) for legal-ai secrets."""
def _coolify_ctx() -> tuple[str, str, str]:
"""Return (base_url, app_uuid, token). Token may be empty."""
return (
os.environ.get("INFISICAL_PROJECT_ID", "9a77b161-f70c-4dd3-9d67-b7ab850cef51"),
os.environ.get("INFISICAL_ENV", "dev"),
os.environ.get("INFISICAL_PATH", "/legal-ai"),
os.environ.get("COOLIFY_URL", "https://coolify.nautilus.marcusgroup.org"),
os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"),
os.environ.get("COOLIFY_API_TOKEN", ""),
)
def _read_infisical_values() -> tuple[dict[str, str], list[str]]:
"""Read all known catalog keys from Infisical. Returns (values, errors)."""
client = _infisical_client()
if client is None:
return {}, ["infisical_unreachable"]
project_id, env, path = _infisical_ctx()
async def _read_coolify_envs() -> tuple[dict[str, list[dict[str, Any]]], list[str]]:
"""Read env vars from Coolify API.
Returns (grouped_by_key, errors). grouped_by_key maps env key →
list of {uuid, key, value} dicts (Coolify may have duplicates per
key for build-time vs runtime — we surface them all).
"""
base_url, app_uuid, token = _coolify_ctx()
if not token:
return {}, ["coolify_token_missing"]
try:
secrets = client.get_all_secrets(
environment=env, project_id=project_id, secret_path=path
)
async with httpx.AsyncClient(timeout=20.0) as http:
resp = await http.get(
f"{base_url}/api/v1/applications/{app_uuid}/envs",
headers={"Authorization": f"Bearer {token}"},
)
except Exception as e:
logger.warning("infisical_read_failed: %s", e)
return {}, ["infisical_read_failed"]
values: dict[str, str] = {}
for s in secrets:
if s.secret_key in ENV_CATALOG:
values[s.secret_key] = s.secret_value
return values, []
logger.warning("coolify_envs_unreachable: %s", e)
return {}, ["coolify_unreachable"]
if resp.status_code >= 400:
logger.warning(
"coolify_envs_failed status=%s body=%s",
resp.status_code, (resp.text or "")[:200],
)
return {}, ["coolify_envs_failed"]
try:
items = resp.json()
except Exception as e:
logger.warning("coolify_envs_parse_failed: %s", e)
return {}, ["coolify_envs_parse_failed"]
grouped: dict[str, list[dict[str, Any]]] = {}
for item in items if isinstance(items, list) else []:
key = item.get("key")
if not key or key not in ENV_CATALOG:
continue
grouped.setdefault(key, []).append(item)
return grouped, []
def _coolify_authoritative_value(entries: list[dict[str, Any]]) -> str | None:
"""Pick the authoritative value when Coolify has multiple entries for a key.
Strategy: if all entries have the same value, return it. If they
differ, return the LAST one (Coolify's own runtime injection order
treats later definitions as overrides) and log a warning so the
UI can display the conflict.
"""
if not entries:
return None
values = {e.get("value") for e in entries}
if len(values) > 1:
logger.warning(
"coolify_env_duplicate_conflict key=%s values=%s",
entries[0].get("key"),
[str(v)[:20] for v in values],
)
return entries[-1].get("value")
def _build_env_var_row(
spec: EnvSpec,
infisical_value: str | None,
coolify_entries: list[dict[str, Any]],
container_value: str | None,
infisical_available: bool = True,
coolify_available: bool,
) -> dict[str, Any]:
"""Build a single response row for an env var.
When infisical_available=False and infisical_value=None, drift is forced
to False (we cannot detect drift without ground truth — UI shows the
'errors' field instead).
`coolify_value` = authoritative value from Coolify (source of truth).
`container_value` = what the running container sees in os.environ.
Drift = coolify_value != container_value (common cause: Coolify env
updated without a redeploy).
When `coolify_available=False` we cannot detect drift; the row
surfaces only container_value with drift=False (UI shows a banner
via the `errors` field).
"""
# When Infisical is unreachable, we have no ground truth — don't fabricate drift.
if not infisical_available and infisical_value is None:
drift = False
if spec.is_secret:
infisical_display: str | None = None
container_display: str | None = (
mask_secret(container_value) if container_value else None
)
else:
infisical_display = None
container_display = container_value
elif spec.is_secret:
i_norm = mask_secret(infisical_value) if infisical_value else None
c_norm = mask_secret(container_value) if container_value else None
# Raw comparison (not hash): values stay in server memory only — never
# logged or returned in the response. mask_secret is applied to display only.
drift = (
(infisical_value or "") != (container_value or "")
and bool(infisical_value or container_value)
coolify_raw = _coolify_authoritative_value(coolify_entries)
has_duplicates = len(coolify_entries) > 1
if not coolify_available:
coolify_display: str | None = None
container_display: str | None = (
mask_secret(container_value) if (spec.is_secret and container_value)
else container_value
)
drift = False
elif spec.is_secret:
coolify_display = mask_secret(coolify_raw) if coolify_raw else None
container_display = mask_secret(container_value) if container_value else None
drift = bool(coolify_raw or container_value) and (
(coolify_raw or "") != (container_value or "")
)
infisical_display = i_norm
container_display = c_norm
else:
infisical_display = infisical_value
coolify_display = coolify_raw
container_display = container_value
drift = (
normalize_for_compare(spec, infisical_value)
normalize_for_compare(spec, coolify_raw)
!= normalize_for_compare(spec, container_value)
)
if infisical_value is None and container_value is None:
if coolify_raw is None and container_value is None:
drift = False
row = spec.to_public_dict()
row.update({
"infisical_value": infisical_display,
"coolify_value": coolify_display,
"container_value": container_display,
"drift": drift,
"has_duplicates": has_duplicates,
})
return row
@app.get("/api/settings/mcp/env")
async def api_mcp_env():
"""List all catalog env vars with Infisical + container values."""
infisical_values, errors = _read_infisical_values()
project_id, env, path = _infisical_ctx()
infisical_available = not errors # empty errors list → Infisical reachable
"""List all catalog env vars with Coolify (authoritative) + container values."""
coolify_envs, errors = await _read_coolify_envs()
_, app_uuid, _ = _coolify_ctx()
coolify_available = not errors
rows = []
for key, spec in ENV_CATALOG.items():
i_val = infisical_values.get(key)
c_val = os.environ.get(key)
rows.append(
_build_env_var_row(spec, i_val, c_val, infisical_available=infisical_available)
_build_env_var_row(
spec,
coolify_envs.get(key, []),
os.environ.get(key),
coolify_available=coolify_available,
)
)
return {
"vars": rows,
"infisical_environment": env,
"infisical_project_id": project_id,
"infisical_path": path,
"coolify_app_uuid": os.environ.get(
"COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"
),
"coolify_app_uuid": app_uuid,
"errors": errors,
}
@@ -2685,7 +2714,7 @@ class McpEnvUpdateRequest(BaseModel):
@app.patch("/api/settings/mcp/env/{key}")
async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest):
"""Update a non-secret env var in Infisical. Requires redeploy to take effect."""
"""Update a non-secret env var in Coolify. Requires redeploy to take effect."""
spec = ENV_CATALOG.get(key)
if spec is None:
raise HTTPException(404, f"Unknown env key: {key}")
@@ -2698,66 +2727,44 @@ async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest):
except ValueError as e:
raise HTTPException(400, str(e))
client = _infisical_client()
if client is None:
raise HTTPException(503, "Infisical not configured")
project_id, env, path = _infisical_ctx()
str_value = "true" if coerced is True else (
"false" if coerced is False else str(coerced)
)
base_url, app_uuid, token = _coolify_ctx()
if not token:
raise HTTPException(503, "COOLIFY_API_TOKEN not configured")
# Coolify's PATCH endpoint upserts by key (creates if not exists,
# updates if exists). For keys with duplicates, this updates ALL
# entries with that key — which is what we want.
try:
# SDK pattern: try update, fall back to create if the secret doesn't exist.
# The Infisical SDK's specific NotFound exception class isn't stable across
# versions, so we inspect the error string. Network/auth errors (which DON'T
# contain 'not found' / 404 / 'does not exist') are re-raised immediately
# so they reach the outer handler and surface as 502 with the real error,
# rather than being silently retried as a create-call.
try:
client.update_secret_by_name(
project_id=project_id,
environment_slug=env,
secret_path=path,
secret_name=key,
secret_value=str_value,
)
except Exception as update_err:
err_text = str(update_err).lower()
looks_like_missing = (
"not found" in err_text
or "does not exist" in err_text
or "404" in err_text
)
if not looks_like_missing:
logger.warning(
"infisical_update_failed key=%s err=%s — not retrying as create",
key, update_err,
)
raise
logger.info(
"infisical_secret_missing key=%s — falling back to create", key,
)
client.create_secret_by_name(
project_id=project_id,
environment_slug=env,
secret_path=path,
secret_name=key,
secret_value=str_value,
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.patch(
f"{base_url}/api/v1/applications/{app_uuid}/envs",
headers={"Authorization": f"Bearer {token}"},
json={"key": key, "value": str_value},
)
except Exception as e:
logger.exception("infisical_write_failed key=%s", key)
raise HTTPException(502, f"Infisical write failed: {e}")
logger.exception("coolify_env_write_unreachable key=%s", key)
raise HTTPException(502, f"Coolify unreachable: {e}")
if resp.status_code >= 400:
body_preview = (resp.text or "")[:200]
logger.warning(
"coolify_env_write_failed key=%s status=%s body=%s",
key, resp.status_code, body_preview,
)
raise HTTPException(
502, f"Coolify update failed: {resp.status_code}{body_preview}"
)
logger.info(
"mcp_env_update key=%s value=%s",
key,
"[masked]" if spec.is_secret else str_value,
)
logger.info("mcp_env_update key=%s value=%s", key, str_value)
return {
"ok": True,
"key": key,
"saved_value": str_value,
"requires_redeploy": True,
"message": "נשמר ב-Infisical. נדרש redeploy כדי שיכנס לתוקף בקונטיינר.",
"message": "נשמר ב-Coolify. נדרש redeploy כדי שהקונטיינר יקרא את הערך החדש.",
}
@@ -2772,18 +2779,16 @@ async def api_mcp_env_redeploy():
raise HTTPException(
429, f"Redeploy בהמתנה: נסה שוב בעוד {wait} שניות."
)
coolify_url = os.environ.get("COOLIFY_URL", "http://158.178.131.193:8000")
coolify_token = os.environ.get("COOLIFY_API_TOKEN", "")
app_uuid = os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio")
if not coolify_token:
base_url, app_uuid, token = _coolify_ctx()
if not token:
raise HTTPException(503, "COOLIFY_API_TOKEN not configured")
async with httpx.AsyncClient(timeout=30.0) as http:
try:
resp = await http.post(
f"{coolify_url}/api/v1/deploy",
f"{base_url}/api/v1/deploy",
params={"uuid": app_uuid, "force": "false"},
headers={"Authorization": f"Bearer {coolify_token}"},
headers={"Authorization": f"Bearer {token}"},
)
except Exception as e:
raise HTTPException(502, f"Coolify unreachable: {e}")
@@ -2825,6 +2830,53 @@ async def api_mcp_registrations():
return list_registrations()
@app.get("/api/settings/mcp/blocks")
async def api_mcp_blocks():
"""List the 12-block decision schema (read-only reference)."""
from legal_mcp.services.block_writer import BLOCK_CONFIG
# CREAC role per block (from docs/block-schema.md). Static map —
# kept here rather than in BLOCK_CONFIG to avoid coupling LLM
# generation config to documentation metadata.
CREAC_ROLE = {
"block-alef": None, "block-bet": None, "block-gimel": None,
"block-dalet": None, "block-yod-bet": None,
"block-he": "Conclusion (preview)",
"block-vav": "Facts (R-context)",
"block-zayin": "Arguments",
"block-chet": "Procedural record",
"block-tet": "Rule (R)",
"block-yod": "C → R → E → A → C (full CREAC)",
"block-yod-alef": "Conclusion (final)",
}
# JWM functional purpose (Federal Judicial Center mapping)
JWM_PURPOSE = {
"block-alef": "Orientation", "block-bet": "Orientation",
"block-gimel": "Orientation", "block-dalet": "Orientation",
"block-he": "Orientation",
"block-vav": "Framing", "block-zayin": "Argumentation",
"block-chet": "Procedural record",
"block-tet": "Deliberation (rules)",
"block-yod": "Deliberation (analysis)",
"block-yod-alef": "Disposition",
"block-yod-bet": "Disposition (signatures)",
}
blocks = []
for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]):
blocks.append({
"id": block_id,
"index": cfg["index"],
"title": cfg["title"],
"gen_type": cfg["gen_type"],
"model": cfg["model"],
"temperature": cfg.get("temp"),
"max_tokens": cfg.get("max_tokens"),
"creac_role": CREAC_ROLE.get(block_id),
"jwm_purpose": JWM_PURPOSE.get(block_id),
})
return {"blocks": blocks, "count": len(blocks)}
# ── Settings: Tag → Company Mappings ──────────────────────────────
@app.get("/api/settings/paperclip-companies")

View File

@@ -1,7 +1,9 @@
# web/mcp_env_catalog.py
"""Static catalog of MCP server env vars exposed in the settings UI.
Whitelist policy: keys not in this catalog are not displayed or editable.
Source of truth: Coolify env vars (read/write via Coolify API).
This file defines the whitelist + types + display metadata.
Keys not in this catalog are not displayed or editable.
"""
from __future__ import annotations

View File

@@ -60,7 +60,16 @@ def _read_paperclip_registrations() -> list[dict[str, Any]]:
instance_dir.name, e,
)
continue
for name, cfg in (data.get("mcpServers") or data or {}).items():
if not isinstance(data, dict):
logger.warning(
"paperclip_mcp_json_unexpected_type: %s type=%s",
instance_dir.name, type(data).__name__,
)
continue
servers = data.get("mcpServers") or data
if not isinstance(servers, dict):
continue
for name, cfg in servers.items():
if not isinstance(cfg, dict):
continue
out.append(_normalize(f"Paperclip ({instance_dir.name})", name, cfg))
@@ -87,9 +96,11 @@ def list_registrations() -> dict[str, Any]:
"error": "host_path_unavailable",
"message": "תיקיית /host לא mounted. ראה runbook להגדרת volumes ב-Coolify.",
}
registrations = (
_read_claude_registrations() + _read_paperclip_registrations()
)
registrations.sort(key=lambda r: (r["client"], r["server_name"]))
return {
"registrations": (
_read_claude_registrations() + _read_paperclip_registrations()
),
"registrations": registrations,
"error": None,
}