Compare commits
21 Commits
2f05cdea2e
...
e90faa9ba4
| Author | SHA1 | Date | |
|---|---|---|---|
| e90faa9ba4 | |||
| ae35934383 | |||
| d1e12619d4 | |||
| 1cb832473c | |||
| 89ce6c79d7 | |||
| 7e3c912899 | |||
| f418686724 | |||
| 8289b4d643 | |||
| 6c129a1350 | |||
| 320b9d3529 | |||
| 394b971856 | |||
| 1da3587334 | |||
| 272e49b6b0 | |||
| 69bdf7b30a | |||
| 2fe73fcce1 | |||
| c30c987ec2 | |||
| 562eae010a | |||
| a3ca32355a | |||
| 55a0eca070 | |||
| 796f9d5f9c | |||
| 70052b0133 |
38
docs/runbooks/coolify-mcp-settings-volumes.md
Normal file
38
docs/runbooks/coolify-mcp-settings-volumes.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!-- docs/runbooks/coolify-mcp-settings-volumes.md -->
|
||||||
|
# Coolify Volume Mounts ל-MCP Settings Page
|
||||||
|
|
||||||
|
## רקע
|
||||||
|
|
||||||
|
טאב **Registrations** בדף `/settings` קורא רישומי MCP מתוך:
|
||||||
|
- `~/.claude.json` (host)
|
||||||
|
- `~/.paperclip/instances/*/mcp.json` (host)
|
||||||
|
|
||||||
|
הקונטיינר של legal-ai חייב גישת קריאה לקבצים אלה דרך volume mounts.
|
||||||
|
בלי המאונט, ה-endpoint יחזיר `error: "host_path_unavailable"` והטאב יציג הודעת אי-זמינות.
|
||||||
|
|
||||||
|
## הוראות
|
||||||
|
|
||||||
|
1. פתח Coolify UI: `http://158.178.131.193:8000`.
|
||||||
|
2. נווט לאפליקציה: legal-ai (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
|
||||||
|
3. לשונית **Storages** → **Add Storage**.
|
||||||
|
4. הוסף שני mounts:
|
||||||
|
|
||||||
|
| Source path (host) | Destination path (container) | Mode |
|
||||||
|
|---|---|---|
|
||||||
|
| `/home/chaim/.claude.json` | `/host/.claude.json` | `ro` |
|
||||||
|
| `/home/chaim/.paperclip` | `/host/.paperclip` | `ro` |
|
||||||
|
|
||||||
|
5. שמור ולחץ **Redeploy**.
|
||||||
|
|
||||||
|
## אימות
|
||||||
|
|
||||||
|
אחרי ה-redeploy:
|
||||||
|
```bash
|
||||||
|
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq
|
||||||
|
```
|
||||||
|
צריך להחזיר `"error": null` ורשימת רישומים.
|
||||||
|
|
||||||
|
## הערה אבטחה
|
||||||
|
|
||||||
|
המאונטים הם read-only. ה-endpoint לא מחזיר ערכי env (רק שמות keys),
|
||||||
|
ולא מאפשר לעדכן את הקבצים.
|
||||||
2158
docs/superpowers/plans/2026-05-04-mcp-settings-page.md
Normal file
2158
docs/superpowers/plans/2026-05-04-mcp-settings-page.md
Normal file
File diff suppressed because it is too large
Load Diff
336
docs/superpowers/specs/2026-05-04-mcp-settings-page-design.md
Normal file
336
docs/superpowers/specs/2026-05-04-mcp-settings-page-design.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# דף הגדרות MCP — איפיון
|
||||||
|
|
||||||
|
**תאריך:** 2026-05-04
|
||||||
|
**מצב:** Draft → ממתין לאישור משתמש
|
||||||
|
**הקשר:** הרחבת `/settings` ב-web-ui עם מידע על MCP server של legal-ai (env vars, tools, registrations).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. מטרה
|
||||||
|
|
||||||
|
לתת ליו"ר/מנהל המערכת מקום מרכזי לראות (ולערוך כשבטוח) את כל מצב התצורה של ה-MCP server, בלי לעבור בין Infisical UI, Coolify UI, וקבצי קונפיגורציה מקומיים.
|
||||||
|
|
||||||
|
## 2. גבולות (Scope)
|
||||||
|
|
||||||
|
**בתוך הסקופ:**
|
||||||
|
- תצוגה + עריכה של env vars לא-סודיים, שמירה ל-Infisical, redeploy ידני של Coolify.
|
||||||
|
- תצוגה (read-only) של env vars סודיים, עם indicator של drift בין Infisical לקונטיינר.
|
||||||
|
- תצוגה (read-only) של רשימת tools שה-MCP server חושף (introspection דינמי).
|
||||||
|
- תצוגה (read-only) של רישומי MCP בקבצי הקונפיגורציה של Claude Code ו-Paperclip.
|
||||||
|
|
||||||
|
**מחוץ לסקופ (אולי בעתיד):**
|
||||||
|
- Enable/disable של tools בודדים.
|
||||||
|
- עריכת `~/.claude.json` או `~/.paperclip/...` מ-UI.
|
||||||
|
- Auth/RBAC חדש (משתמש ב-auth קיים של הדף — אין כרגע).
|
||||||
|
- ניהול secrets — נשאר ב-Infisical UI.
|
||||||
|
- Auto-redeploy אחרי שמירה (משתמש לוחץ Redeploy ידנית).
|
||||||
|
|
||||||
|
## 3. ארכיטקטורה
|
||||||
|
|
||||||
|
### 3.1 מבנה דף (Frontend)
|
||||||
|
|
||||||
|
`/settings` הופך לדף מבוסס-טאבים (`shadcn/Tabs`):
|
||||||
|
|
||||||
|
| Tab | תוכן | מצב |
|
||||||
|
|---|---|---|
|
||||||
|
| Paperclip | התוכן הקיים: Tag mappings + Companies | קיים, ללא שינוי לוגי |
|
||||||
|
| Environment | env vars של MCP server, Infisical / Container | חדש, עריכה |
|
||||||
|
| Tools | רשימת tools של ה-MCP server | חדש, read-only |
|
||||||
|
| Registrations | רישומי MCP ב-Claude Code ו-Paperclip | חדש, read-only |
|
||||||
|
|
||||||
|
טאב ברירת מחדל: `Paperclip`.
|
||||||
|
|
||||||
|
### 3.2 שכבת Backend (FastAPI ב-`web/app.py`)
|
||||||
|
|
||||||
|
#### Endpoints חדשים
|
||||||
|
|
||||||
|
| Path | Method | תיאור |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/settings/mcp/env` | GET | מחזיר רשימת env vars מאוחדת |
|
||||||
|
| `/api/settings/mcp/env/{key}` | PATCH | מעדכן ערך ב-Infisical (רק לא-סודיים) |
|
||||||
|
| `/api/settings/mcp/env/redeploy` | POST | מפעיל Coolify redeploy |
|
||||||
|
| `/api/settings/mcp/tools` | GET | מחזיר רשימת tools של MCP server |
|
||||||
|
| `/api/settings/mcp/registrations` | GET | מחזיר רישומי MCP מ-`/host/.claude.json` ומ-`/host/.paperclip/instances/*/mcp.json` |
|
||||||
|
|
||||||
|
#### Catalog של env vars
|
||||||
|
|
||||||
|
קובץ חדש: `web/mcp_env_catalog.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Any
|
||||||
|
|
||||||
|
EnvType = Literal["bool", "int", "float", "string", "enum"]
|
||||||
|
EnvCategory = Literal["multimodal", "rerank", "halacha", "credentials", "connection", "general"]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnvSpec:
|
||||||
|
key: str
|
||||||
|
category: EnvCategory
|
||||||
|
type: EnvType
|
||||||
|
description: str
|
||||||
|
is_secret: bool
|
||||||
|
is_editable: bool
|
||||||
|
default: Any = None
|
||||||
|
min: float | None = None
|
||||||
|
max: float | None = None
|
||||||
|
enum_values: list[str] | None = None
|
||||||
|
|
||||||
|
ENV_CATALOG: dict[str, EnvSpec] = {
|
||||||
|
# multimodal
|
||||||
|
"MULTIMODAL_ENABLED": EnvSpec("MULTIMODAL_ENABLED", "multimodal", "bool",
|
||||||
|
"הפעלת page-image embeddings", False, True, default=False),
|
||||||
|
"MULTIMODAL_MODEL": EnvSpec("MULTIMODAL_MODEL", "multimodal", "string",
|
||||||
|
"מודל multimodal של Voyage", False, True, default="voyage-multimodal-3"),
|
||||||
|
"MULTIMODAL_DPI": EnvSpec("MULTIMODAL_DPI", "multimodal", "int",
|
||||||
|
"DPI ל-rendering של עמוד למודל", False, True, default=144, min=72, max=300),
|
||||||
|
"MULTIMODAL_THUMB_DPI": EnvSpec("MULTIMODAL_THUMB_DPI", "multimodal", "int",
|
||||||
|
"DPI ל-thumbnail בתצוגה", False, True, default=96, min=72, max=200),
|
||||||
|
"MULTIMODAL_TEXT_WEIGHT": EnvSpec("MULTIMODAL_TEXT_WEIGHT", "multimodal", "float",
|
||||||
|
"משקל text vs image ב-RRF", False, True, default=0.5, min=0.0, max=1.0),
|
||||||
|
"MULTIMODAL_RRF_K": EnvSpec("MULTIMODAL_RRF_K", "multimodal", "int",
|
||||||
|
"RRF damping constant", False, True, default=60, min=1, max=200),
|
||||||
|
# rerank
|
||||||
|
"VOYAGE_RERANK_ENABLED": EnvSpec("VOYAGE_RERANK_ENABLED", "rerank", "bool",
|
||||||
|
"הפעלת cross-encoder rerank", False, True, default=False),
|
||||||
|
"VOYAGE_RERANK_MODEL": EnvSpec("VOYAGE_RERANK_MODEL", "rerank", "string",
|
||||||
|
"מודל rerank", False, True, default="rerank-2"),
|
||||||
|
"VOYAGE_RERANK_FETCH_K": EnvSpec("VOYAGE_RERANK_FETCH_K", "rerank", "int",
|
||||||
|
"מספר candidates לפני rerank", False, True, default=50, min=10, max=200),
|
||||||
|
# halacha
|
||||||
|
"HALACHA_AUTO_APPROVE_THRESHOLD": EnvSpec("HALACHA_AUTO_APPROVE_THRESHOLD",
|
||||||
|
"halacha", "float", "סף confidence ל-auto-approve",
|
||||||
|
False, True, default=0.80, min=0.0, max=1.0),
|
||||||
|
# general
|
||||||
|
"VOYAGE_MODEL": EnvSpec("VOYAGE_MODEL", "general", "string",
|
||||||
|
"מודל embedding ראשי", False, True, default="voyage-law-2"),
|
||||||
|
"AUDIT_ENABLED": EnvSpec("AUDIT_ENABLED", "general", "bool",
|
||||||
|
"הפעלת audit log", False, True, default=True),
|
||||||
|
# credentials (read-only, masked)
|
||||||
|
"VOYAGE_API_KEY": EnvSpec("VOYAGE_API_KEY", "credentials", "string",
|
||||||
|
"Voyage AI API key", True, False),
|
||||||
|
"GOOGLE_CLOUD_VISION_API_KEY": EnvSpec("GOOGLE_CLOUD_VISION_API_KEY",
|
||||||
|
"credentials", "string", "Google Cloud Vision API key", True, False),
|
||||||
|
"INFISICAL_TOKEN": EnvSpec("INFISICAL_TOKEN", "credentials", "string",
|
||||||
|
"Infisical SDK token", True, False),
|
||||||
|
# connection (read-only — מסוכן לשנות runtime)
|
||||||
|
"POSTGRES_URL": EnvSpec("POSTGRES_URL", "connection", "string",
|
||||||
|
"PostgreSQL connection URL", True, False),
|
||||||
|
"REDIS_URL": EnvSpec("REDIS_URL", "connection", "string",
|
||||||
|
"Redis connection URL", False, False),
|
||||||
|
"DATA_DIR": EnvSpec("DATA_DIR", "connection", "string",
|
||||||
|
"Data directory path", False, False),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
המקור: `mcp-server/src/legal_mcp/config.py`. כל מפתח שלא ב-catalog לא מוצג (whitelist policy).
|
||||||
|
|
||||||
|
#### Response shape של `GET /api/settings/mcp/env`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vars": [
|
||||||
|
{
|
||||||
|
"key": "MULTIMODAL_ENABLED",
|
||||||
|
"category": "multimodal",
|
||||||
|
"type": "bool",
|
||||||
|
"description": "הפעלת page-image embeddings",
|
||||||
|
"is_secret": false,
|
||||||
|
"is_editable": true,
|
||||||
|
"default": false,
|
||||||
|
"infisical_value": "true",
|
||||||
|
"container_value": "true",
|
||||||
|
"drift": false,
|
||||||
|
"min": null, "max": null, "enum_values": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "VOYAGE_API_KEY",
|
||||||
|
"category": "credentials",
|
||||||
|
"type": "string",
|
||||||
|
"description": "Voyage AI API key",
|
||||||
|
"is_secret": true,
|
||||||
|
"is_editable": false,
|
||||||
|
"infisical_value": "****",
|
||||||
|
"container_value": "****",
|
||||||
|
"drift": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"infisical_environment": "dev",
|
||||||
|
"coolify_app_uuid": "gyjo0mtw2c42ej3xxvbz8zio",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `infisical_value`: דרך `InfisicalSDKClient.get_secret(...)`. אם יש שגיאה → `null` ועדכון `errors`.
|
||||||
|
- `container_value`: `os.environ.get(key)`. אם לא מוגדר → `null`.
|
||||||
|
- `drift`: `infisical_value != container_value` (אחרי normalization של bool/int/float; secrets לא משווים ערכים גולמיים — רק hash).
|
||||||
|
- ל-secret: שני הערכים מוחזרים מטושטשים (`"****" + last_4`); השוואת drift על ה-hash בלבד.
|
||||||
|
|
||||||
|
#### Save flow ב-`PATCH /api/settings/mcp/env/{key}`
|
||||||
|
|
||||||
|
1. ולידציה: הקיי קיים ב-catalog ו-`is_editable=true`. אם לא → 400.
|
||||||
|
2. ולידציה לפי type: int/float ב-טווח, bool מוסב מ-string, enum בערכים מותרים.
|
||||||
|
3. כתיבה ל-Infisical:
|
||||||
|
```python
|
||||||
|
client.update_secret(
|
||||||
|
project_id=INFISICAL_PROJECT_ID,
|
||||||
|
environment_slug=INFISICAL_ENV, # "dev" כברירת מחדל
|
||||||
|
secret_path="/legal-ai",
|
||||||
|
secret_name=key,
|
||||||
|
secret_value=str(value),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
4. Audit log: `logger.info("mcp_env_update", extra={"key": key, "value": value if not is_secret else "[masked]"})`.
|
||||||
|
5. Response: `{"ok": true, "requires_redeploy": true, "message": "נשמר ב-Infisical. נדרש redeploy."}`.
|
||||||
|
|
||||||
|
#### Redeploy flow ב-`POST /api/settings/mcp/env/redeploy`
|
||||||
|
|
||||||
|
1. קריאה ל-Coolify API: `POST /api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=false`.
|
||||||
|
2. אסימון: `COOLIFY_API_TOKEN` (מ-Infisical).
|
||||||
|
3. Polling: קריאה ל-`/api/v1/deployments/{deployment_uuid}` כל 5 שניות, עד `status="finished"` או `status="failed"` (max 10 דקות).
|
||||||
|
4. UI מציג סטטוס מתעדכן (פשוט: spinner + הודעת סטטוס; לא נדרש streaming).
|
||||||
|
|
||||||
|
#### Tools introspection ב-`GET /api/settings/mcp/tools`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from legal_mcp.server import mcp # FastMCP instance
|
||||||
|
|
||||||
|
async def api_mcp_tools():
|
||||||
|
tools = await mcp.list_tools() # FastMCP API
|
||||||
|
return {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"module": _module_for_tool(t.name), # מ-tools/__init__.py
|
||||||
|
"params_schema": t.inputSchema,
|
||||||
|
"source_location": _source_location(t), # f"{file}:{line}"
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_module_for_tool` ו-`_source_location` נכתבים ב-`web/mcp_introspection.py` עם קריאת `inspect.getfile()` ו-`inspect.getsourcelines()`.
|
||||||
|
|
||||||
|
#### Registrations ב-`GET /api/settings/mcp/registrations`
|
||||||
|
|
||||||
|
קורא:
|
||||||
|
1. `/host/.claude.json` — תחת `mcpServers` או `projects.<path>.mcpServers`.
|
||||||
|
2. `/host/.paperclip/instances/*/mcp.json` — לכל instance בנפרד.
|
||||||
|
|
||||||
|
לכל רישום: `{client, instance_name?, server_name, command, args, cwd, env_keys}`.
|
||||||
|
- `env_keys`: רק שמות, לא ערכים.
|
||||||
|
- אם command/args מכילים paths רגישים — מוצגים as-is (לא secrets).
|
||||||
|
|
||||||
|
#### Coolify config — volume mounts נדרשים
|
||||||
|
|
||||||
|
לפני שהפיצ'ר עולה לפרודקשן, יש לוודא ב-Coolify (UUID `gyjo0mtw2c42ej3xxvbz8zio`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /home/chaim/.claude.json:/host/.claude.json:ro
|
||||||
|
- /home/chaim/.paperclip:/host/.paperclip:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
המימוש כולל סקריפט/הוראה אופרטיבית להוסיף את ה-mounts (לא חלק מקוד הפרויקט — שינוי תצורה).
|
||||||
|
|
||||||
|
### 3.3 שכבת Frontend
|
||||||
|
|
||||||
|
#### קובץ קיים: `web-ui/src/lib/api/settings.ts`
|
||||||
|
|
||||||
|
מורחב עם hooks חדשים:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// קריאות חדשות
|
||||||
|
export function useMcpEnv() { /* GET /api/settings/mcp/env */ }
|
||||||
|
export function useUpdateMcpEnv() { /* PATCH /api/settings/mcp/env/{key} */ }
|
||||||
|
export function useMcpRedeploy() { /* POST /api/settings/mcp/env/redeploy */ }
|
||||||
|
export function useMcpTools() { /* GET /api/settings/mcp/tools */ }
|
||||||
|
export function useMcpRegistrations() { /* GET /api/settings/mcp/registrations */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### קבצי components חדשים תחת `web-ui/src/app/settings/_components/`
|
||||||
|
|
||||||
|
```
|
||||||
|
_components/
|
||||||
|
├── paperclip-tab.tsx ← העברת התוכן הקיים מ-page.tsx
|
||||||
|
├── environment-tab.tsx ← רשימת קבוצות + EnvVarRow
|
||||||
|
├── env-var-row.tsx ← שורה אחת של env var
|
||||||
|
├── env-var-editor.tsx ← input controls לפי type
|
||||||
|
├── tools-tab.tsx ← טבלה + drawer
|
||||||
|
├── tool-detail-drawer.tsx ← פרטי tool
|
||||||
|
├── registrations-tab.tsx ← כרטיסים לפי client
|
||||||
|
└── drift-badge.tsx ← badge ויזואלי
|
||||||
|
```
|
||||||
|
|
||||||
|
`page.tsx` הופך לאחראי רק על ה-Tabs ולעטיפה.
|
||||||
|
|
||||||
|
#### חוויית עריכת env var
|
||||||
|
|
||||||
|
לחיצה על שורה → התרחבות (accordion) → הצגת editor + שני ערכים (Infisical / Container) + כפתור "שמור".
|
||||||
|
|
||||||
|
לחיצה על "שמור":
|
||||||
|
1. PATCH → toast הצלחה: "נשמר ב-Infisical. לחץ Redeploy כדי להחיל בקונטיינר."
|
||||||
|
2. השורה מסומנת כ-"pending redeploy" עד ה-redeploy הבא.
|
||||||
|
3. כפתור "Redeploy now" קבוע בתחתית הטאב, מודגש כשיש שינויים pending.
|
||||||
|
|
||||||
|
#### חוויית Tools
|
||||||
|
|
||||||
|
טבלה לפי module. שורה → drawer מימין עם schema + תיאור + מיקום בקוד.
|
||||||
|
|
||||||
|
#### חוויית Registrations
|
||||||
|
|
||||||
|
כרטיס לכל client (Claude Code, Paperclip) → פירוט הרישום: command/args/cwd/env_keys.
|
||||||
|
|
||||||
|
## 4. טיפול בשגיאות
|
||||||
|
|
||||||
|
| תרחיש | התנהגות |
|
||||||
|
|---|---|
|
||||||
|
| Infisical לא זמין | `errors: ["infisical_unreachable"]` ב-GET. ערך infisical = null. UI מציג `?` במקום הערך + tooltip |
|
||||||
|
| Coolify redeploy נכשל | toast עם פרטי השגיאה. ערך נשמר ב-Infisical, מסומן pending |
|
||||||
|
| volume mount חסר ב-Coolify | endpoint registrations מחזיר `{registrations: [], error: "host_path_unavailable"}`. UI מציג הודעה |
|
||||||
|
| ניסיון עריכה של secret | 400 עם הודעה ברורה |
|
||||||
|
| ערך לא חוקי לפי type | 400 עם הודעת ולידציה ספציפית |
|
||||||
|
| FastMCP introspection נכשלת | 500. לוג שגיאה. UI מציג fallback |
|
||||||
|
|
||||||
|
## 5. בטיחות
|
||||||
|
|
||||||
|
- **לא להציג ערכי secret** — ה-API מחזיר תמיד `****<last_4>` עבור secrets.
|
||||||
|
- **Drift detection לא חושף** — השוואה על hash, לא על ערך גולמי.
|
||||||
|
- **PATCH על secret חסום ב-server** — לא רק ב-UI.
|
||||||
|
- **No raw `os.environ` dump** — ה-endpoint מחזיר רק keys ב-catalog.
|
||||||
|
- **Audit log** — כל PATCH מתועד ל-`logger.info` (key + ערך אם לא-סודי).
|
||||||
|
|
||||||
|
## 6. שלבי מימוש (overview ל-plan)
|
||||||
|
|
||||||
|
1. Catalog + endpoint `GET /api/settings/mcp/env` (ללא עריכה).
|
||||||
|
2. UI טאב Environment — read-only עם drift badges.
|
||||||
|
3. PATCH endpoint + UI editor.
|
||||||
|
4. Redeploy endpoint + UI button.
|
||||||
|
5. Tools introspection + UI.
|
||||||
|
6. Volume mounts הוראה (manual Coolify config) + Registrations endpoint + UI.
|
||||||
|
7. בדיקות ידניות end-to-end.
|
||||||
|
|
||||||
|
## 7. שאלות פתוחות (להבהרה לפני plan)
|
||||||
|
|
||||||
|
- **סביבת Infisical** — `dev`? `nautilus`? להחליט סופית. ברירת מחדל ב-spec: `dev`. ייתכן ויהיה ניתן לקבוע ב-env var (`INFISICAL_ENV`).
|
||||||
|
- **Path ב-Infisical** — `/legal-ai`? `/legal-ai/mcp`? להחליט לפי `_GUIDELINES/SAVE_SECRET_RULES`.
|
||||||
|
- **Auth** — אין כרגע על `/settings`. להוסיף לפחות "are you sure" dialog לפני PATCH של ערך משמעותי?
|
||||||
|
|
||||||
|
## 8. בדיקות
|
||||||
|
|
||||||
|
**ידני (אין test suite ל-frontend):**
|
||||||
|
- ✓ פתיחת `/settings` — Paperclip tab עובד כקודם.
|
||||||
|
- ✓ Environment tab — מציג env vars מקבץ catalog בלבד.
|
||||||
|
- ✓ Drift detection — שינוי ידני של env בקונטיינר → drift badge מופיע.
|
||||||
|
- ✓ עריכת `MULTIMODAL_TEXT_WEIGHT` ל-`0.7` → נשמר ב-Infisical.
|
||||||
|
- ✓ Redeploy → ערך חדש נכנס לתוקף בקונטיינר.
|
||||||
|
- ✓ ניסיון עריכת `VOYAGE_API_KEY` → חסום + הודעה.
|
||||||
|
- ✓ Tools tab — מציג את כל ה-tools של legal_mcp.
|
||||||
|
- ✓ Registrations tab — מציג את `~/.claude.json` ו-Paperclip instances.
|
||||||
|
|
||||||
|
**Backend tests** ב-`web/tests/` (אם קיימים — אחרת לדלג):
|
||||||
|
- catalog rejects unknown key
|
||||||
|
- PATCH על secret נחסם
|
||||||
|
- ולידציה של min/max
|
||||||
@@ -20,6 +20,7 @@ dependencies = [
|
|||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
|
"infisicalsdk>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
128
web-ui/src/app/settings/_components/blocks-tab.tsx
Normal file
128
web-ui/src/app/settings/_components/blocks-tab.tsx
Normal 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 <span className="tabular-nums">{block.temperature}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{block.max_tokens !== null && (
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
max <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
web-ui/src/app/settings/_components/drift-badge.tsx
Normal file
39
web-ui/src/app/settings/_components/drift-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
web-ui/src/app/settings/_components/env-var-editor.tsx
Normal file
76
web-ui/src/app/settings/_components/env-var-editor.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
web-ui/src/app/settings/_components/env-var-row.tsx
Normal file
123
web-ui/src/app/settings/_components/env-var-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
web-ui/src/app/settings/_components/environment-tab.tsx
Normal file
139
web-ui/src/app/settings/_components/environment-tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
web-ui/src/app/settings/_components/paperclip-tab.tsx
Normal file
225
web-ui/src/app/settings/_components/paperclip-tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
web-ui/src/app/settings/_components/registrations-tab.tsx
Normal file
134
web-ui/src/app/settings/_components/registrations-tab.tsx
Normal 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" />
|
||||||
|
סה"כ {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
web-ui/src/app/settings/_components/tool-detail-drawer.tsx
Normal file
65
web-ui/src/app/settings/_components/tool-detail-drawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
web-ui/src/app/settings/_components/tools-tab.tsx
Normal file
83
web-ui/src/app/settings/_components/tools-tab.tsx
Normal 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" />
|
||||||
|
סה"כ {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
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 { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { PaperclipTab } from "./_components/paperclip-tab";
|
||||||
import { Button } from "@/components/ui/button";
|
import { EnvironmentTab } from "./_components/environment-tab";
|
||||||
import { Input } from "@/components/ui/input";
|
import { ToolsTab } from "./_components/tools-tab";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { RegistrationsTab } from "./_components/registrations-tab";
|
||||||
import {
|
import { BlocksTab } from "./_components/blocks-tab";
|
||||||
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 default function SettingsPage() {
|
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 (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@@ -88,164 +24,42 @@ export default function SettingsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
<h1 className="text-navy mb-0">הגדרות</h1>
|
<h1 className="text-navy mb-0">הגדרות</h1>
|
||||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך
|
תצורת המערכת, MCP server, ו-Paperclip integration.
|
||||||
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
|
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
{/* Companies overview */}
|
<Tabs defaultValue="paperclip" className="space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<TabsList>
|
||||||
<CardContent className="px-6 py-5">
|
<TabsTrigger value="paperclip">
|
||||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
||||||
<Building2 className="w-4 h-4" />
|
Paperclip
|
||||||
חברות ב-Paperclip
|
</TabsTrigger>
|
||||||
</h2>
|
<TabsTrigger value="environment">
|
||||||
{loadingCompanies ? (
|
<Server className="w-4 h-4" data-icon="inline-start" />
|
||||||
<Skeleton className="h-12 w-full" />
|
Environment
|
||||||
) : !companies?.length ? (
|
</TabsTrigger>
|
||||||
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
|
<TabsTrigger value="tools">
|
||||||
) : (
|
<Wrench className="w-4 h-4" data-icon="inline-start" />
|
||||||
<div className="flex flex-wrap gap-3">
|
Tools
|
||||||
{companies.map((c) => (
|
</TabsTrigger>
|
||||||
<div
|
<TabsTrigger value="blocks">
|
||||||
key={c.id}
|
<Layers className="w-4 h-4" data-icon="inline-start" />
|
||||||
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
|
Blocks
|
||||||
>
|
</TabsTrigger>
|
||||||
<span className="text-sm font-medium text-ink">{c.name}</span>
|
<TabsTrigger value="registrations">
|
||||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
<Plug className="w-4 h-4" data-icon="inline-start" />
|
||||||
{c.prefix}
|
Registrations
|
||||||
</Badge>
|
</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tag mappings */}
|
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
||||||
<CardContent className="px-6 py-5">
|
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
||||||
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
|
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
||||||
<Tags className="w-4 h-4" />
|
<TabsContent value="registrations"><RegistrationsTab /></TabsContent>
|
||||||
מיפוי תגיות
|
</Tabs>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
33
web-ui/src/components/ui/switch.tsx
Normal file
33
web-ui/src/components/ui/switch.tsx
Normal 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 }
|
||||||
@@ -55,3 +55,141 @@ export function useDeleteTagMapping() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
334
web/app.py
334
web/app.py
@@ -26,6 +26,7 @@ from typing import Any
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, processor, proofreader, research_md
|
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, processor, proofreader, research_md
|
||||||
@@ -35,6 +36,13 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl
|
|||||||
_web_dir = Path(__file__).resolve().parent
|
_web_dir = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(_web_dir.parent))
|
sys.path.insert(0, str(_web_dir.parent))
|
||||||
from web.gitea_client import commit_and_push, create_repo, setup_remote_and_push
|
from web.gitea_client import commit_and_push, create_repo, setup_remote_and_push
|
||||||
|
from web.mcp_env_catalog import (
|
||||||
|
ENV_CATALOG,
|
||||||
|
EnvSpec,
|
||||||
|
coerce,
|
||||||
|
mask_secret,
|
||||||
|
normalize_for_compare,
|
||||||
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
@@ -2543,6 +2551,332 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
# multiple redundant Coolify builds.
|
||||||
|
_LAST_REDEPLOY_AT: float = 0.0
|
||||||
|
_REDEPLOY_MIN_INTERVAL_SEC: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _coolify_ctx() -> tuple[str, str, str]:
|
||||||
|
"""Return (base_url, app_uuid, token). Token may be empty."""
|
||||||
|
return (
|
||||||
|
os.environ.get("COOLIFY_URL", "https://coolify.nautilus.marcusgroup.org"),
|
||||||
|
os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"),
|
||||||
|
os.environ.get("COOLIFY_API_TOKEN", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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("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,
|
||||||
|
coolify_entries: list[dict[str, Any]],
|
||||||
|
container_value: str | None,
|
||||||
|
coolify_available: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a single response row for an env var.
|
||||||
|
|
||||||
|
`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).
|
||||||
|
"""
|
||||||
|
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 "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
coolify_display = coolify_raw
|
||||||
|
container_display = container_value
|
||||||
|
drift = (
|
||||||
|
normalize_for_compare(spec, coolify_raw)
|
||||||
|
!= normalize_for_compare(spec, container_value)
|
||||||
|
)
|
||||||
|
if coolify_raw is None and container_value is None:
|
||||||
|
drift = False
|
||||||
|
row = spec.to_public_dict()
|
||||||
|
row.update({
|
||||||
|
"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 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():
|
||||||
|
rows.append(
|
||||||
|
_build_env_var_row(
|
||||||
|
spec,
|
||||||
|
coolify_envs.get(key, []),
|
||||||
|
os.environ.get(key),
|
||||||
|
coolify_available=coolify_available,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"vars": rows,
|
||||||
|
"coolify_app_uuid": app_uuid,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class McpEnvUpdateRequest(BaseModel):
|
||||||
|
value: Any
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/settings/mcp/env/{key}")
|
||||||
|
async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest):
|
||||||
|
"""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}")
|
||||||
|
if spec.is_secret:
|
||||||
|
raise HTTPException(400, f"Cannot edit secret: {key}")
|
||||||
|
if not spec.is_editable:
|
||||||
|
raise HTTPException(400, f"Read-only: {key}")
|
||||||
|
try:
|
||||||
|
coerced = coerce(spec, req.value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
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:
|
||||||
|
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("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, str_value)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"key": key,
|
||||||
|
"saved_value": str_value,
|
||||||
|
"requires_redeploy": True,
|
||||||
|
"message": "נשמר ב-Coolify. נדרש redeploy כדי שהקונטיינר יקרא את הערך החדש.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings/mcp/env/redeploy")
|
||||||
|
async def api_mcp_env_redeploy():
|
||||||
|
"""Trigger Coolify redeploy of the legal-ai app."""
|
||||||
|
global _LAST_REDEPLOY_AT
|
||||||
|
now = time.time()
|
||||||
|
elapsed = now - _LAST_REDEPLOY_AT
|
||||||
|
if elapsed < _REDEPLOY_MIN_INTERVAL_SEC:
|
||||||
|
wait = int(_REDEPLOY_MIN_INTERVAL_SEC - elapsed)
|
||||||
|
raise HTTPException(
|
||||||
|
429, f"Redeploy בהמתנה: נסה שוב בעוד {wait} שניות."
|
||||||
|
)
|
||||||
|
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"{base_url}/api/v1/deploy",
|
||||||
|
params={"uuid": app_uuid, "force": "false"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Coolify unreachable: {e}")
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body_preview = (resp.text or "")[:200]
|
||||||
|
raise HTTPException(
|
||||||
|
502, f"Coolify deploy failed: {resp.status_code} — {body_preview}"
|
||||||
|
)
|
||||||
|
data = resp.json() if resp.content else {}
|
||||||
|
deployment_uuid = (
|
||||||
|
data.get("deployment_uuid")
|
||||||
|
or (data.get("deployments") or [{}])[0].get("deployment_uuid")
|
||||||
|
)
|
||||||
|
logger.info("mcp_env_redeploy triggered uuid=%s", deployment_uuid)
|
||||||
|
_LAST_REDEPLOY_AT = now
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"deployment_uuid": deployment_uuid,
|
||||||
|
"message": "Redeploy הופעל. הקונטיינר יחזור תוך 2-4 דקות.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings/mcp/tools")
|
||||||
|
async def api_mcp_tools():
|
||||||
|
"""List all MCP tools registered in legal_mcp."""
|
||||||
|
from web.mcp_introspection import list_mcp_tools
|
||||||
|
try:
|
||||||
|
tools = await list_mcp_tools()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("mcp_tools_introspection_failed")
|
||||||
|
raise HTTPException(500, f"Tools introspection failed: {e}")
|
||||||
|
return {"tools": tools, "count": len(tools)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings/mcp/registrations")
|
||||||
|
async def api_mcp_registrations():
|
||||||
|
"""List MCP server registrations from host config files."""
|
||||||
|
from web.mcp_registrations import list_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 ──────────────────────────────
|
# ── Settings: Tag → Company Mappings ──────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/settings/paperclip-companies")
|
@app.get("/api/settings/paperclip-companies")
|
||||||
|
|||||||
205
web/mcp_env_catalog.py
Normal file
205
web/mcp_env_catalog.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# web/mcp_env_catalog.py
|
||||||
|
"""Static catalog of MCP server env vars exposed in the settings UI.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
EnvType = Literal["bool", "int", "float", "string"]
|
||||||
|
EnvCategory = Literal[
|
||||||
|
"multimodal", "rerank", "halacha", "credentials", "connection", "general"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnvSpec:
|
||||||
|
key: str
|
||||||
|
category: EnvCategory
|
||||||
|
type: EnvType
|
||||||
|
description: str
|
||||||
|
is_secret: bool
|
||||||
|
is_editable: bool
|
||||||
|
default: Any = None
|
||||||
|
min: float | None = None
|
||||||
|
max: float | None = None
|
||||||
|
enum_values: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
def to_public_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return spec fields as a dict. Does NOT mask secret values — caller must handle."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
ENV_CATALOG: dict[str, EnvSpec] = {
|
||||||
|
# ── multimodal ─────────────────────────────────────────────────
|
||||||
|
"MULTIMODAL_ENABLED": EnvSpec(
|
||||||
|
"MULTIMODAL_ENABLED", "multimodal", "bool",
|
||||||
|
"הפעלת page-image embeddings (voyage-multimodal-3)",
|
||||||
|
is_secret=False, is_editable=True, default=False,
|
||||||
|
),
|
||||||
|
"MULTIMODAL_MODEL": EnvSpec(
|
||||||
|
"MULTIMODAL_MODEL", "multimodal", "string",
|
||||||
|
"מודל multimodal של Voyage",
|
||||||
|
is_secret=False, is_editable=True, default="voyage-multimodal-3",
|
||||||
|
),
|
||||||
|
"MULTIMODAL_DPI": EnvSpec(
|
||||||
|
"MULTIMODAL_DPI", "multimodal", "int",
|
||||||
|
"DPI ל-rendering של עמוד למודל",
|
||||||
|
is_secret=False, is_editable=True, default=144, min=72, max=300,
|
||||||
|
),
|
||||||
|
"MULTIMODAL_THUMB_DPI": EnvSpec(
|
||||||
|
"MULTIMODAL_THUMB_DPI", "multimodal", "int",
|
||||||
|
"DPI ל-thumbnail בתצוגה",
|
||||||
|
is_secret=False, is_editable=True, default=96, min=72, max=200,
|
||||||
|
),
|
||||||
|
"MULTIMODAL_TEXT_WEIGHT": EnvSpec(
|
||||||
|
"MULTIMODAL_TEXT_WEIGHT", "multimodal", "float",
|
||||||
|
"משקל text vs image ב-RRF (0=image בלבד, 1=text בלבד)",
|
||||||
|
is_secret=False, is_editable=True, default=0.5, min=0.0, max=1.0,
|
||||||
|
),
|
||||||
|
"MULTIMODAL_RRF_K": EnvSpec(
|
||||||
|
"MULTIMODAL_RRF_K", "multimodal", "int",
|
||||||
|
"RRF damping constant",
|
||||||
|
is_secret=False, is_editable=True, default=60, min=1, max=200,
|
||||||
|
),
|
||||||
|
# ── rerank ─────────────────────────────────────────────────────
|
||||||
|
"VOYAGE_RERANK_ENABLED": EnvSpec(
|
||||||
|
"VOYAGE_RERANK_ENABLED", "rerank", "bool",
|
||||||
|
"הפעלת cross-encoder rerank",
|
||||||
|
is_secret=False, is_editable=True, default=False,
|
||||||
|
),
|
||||||
|
"VOYAGE_RERANK_MODEL": EnvSpec(
|
||||||
|
"VOYAGE_RERANK_MODEL", "rerank", "string",
|
||||||
|
"מודל rerank",
|
||||||
|
is_secret=False, is_editable=True, default="rerank-2",
|
||||||
|
),
|
||||||
|
"VOYAGE_RERANK_FETCH_K": EnvSpec(
|
||||||
|
"VOYAGE_RERANK_FETCH_K", "rerank", "int",
|
||||||
|
"מספר candidates לפני rerank",
|
||||||
|
is_secret=False, is_editable=True, default=50, min=10, max=200,
|
||||||
|
),
|
||||||
|
# ── halacha ────────────────────────────────────────────────────
|
||||||
|
"HALACHA_AUTO_APPROVE_THRESHOLD": EnvSpec(
|
||||||
|
"HALACHA_AUTO_APPROVE_THRESHOLD", "halacha", "float",
|
||||||
|
"סף confidence ל-auto-approve של הלכות שחולצו",
|
||||||
|
is_secret=False, is_editable=True, default=0.80, min=0.0, max=1.0,
|
||||||
|
),
|
||||||
|
# ── general ────────────────────────────────────────────────────
|
||||||
|
"VOYAGE_MODEL": EnvSpec(
|
||||||
|
"VOYAGE_MODEL", "general", "string",
|
||||||
|
"מודל embedding ראשי",
|
||||||
|
is_secret=False, is_editable=True, default="voyage-law-2",
|
||||||
|
),
|
||||||
|
"AUDIT_ENABLED": EnvSpec(
|
||||||
|
"AUDIT_ENABLED", "general", "bool",
|
||||||
|
"הפעלת audit log",
|
||||||
|
is_secret=False, is_editable=True, default=True,
|
||||||
|
),
|
||||||
|
# ── credentials (read-only, masked) ────────────────────────────
|
||||||
|
"VOYAGE_API_KEY": EnvSpec(
|
||||||
|
"VOYAGE_API_KEY", "credentials", "string",
|
||||||
|
"Voyage AI API key",
|
||||||
|
is_secret=True, is_editable=False,
|
||||||
|
),
|
||||||
|
"GOOGLE_CLOUD_VISION_API_KEY": EnvSpec(
|
||||||
|
"GOOGLE_CLOUD_VISION_API_KEY", "credentials", "string",
|
||||||
|
"Google Cloud Vision API key (OCR)",
|
||||||
|
is_secret=True, is_editable=False,
|
||||||
|
),
|
||||||
|
"INFISICAL_TOKEN": EnvSpec(
|
||||||
|
"INFISICAL_TOKEN", "credentials", "string",
|
||||||
|
"Infisical SDK token",
|
||||||
|
is_secret=True, is_editable=False,
|
||||||
|
),
|
||||||
|
# ── connection (read-only — שינוי runtime מסוכן) ──────────────
|
||||||
|
"POSTGRES_URL": EnvSpec(
|
||||||
|
"POSTGRES_URL", "connection", "string",
|
||||||
|
"PostgreSQL connection URL",
|
||||||
|
is_secret=True, is_editable=False,
|
||||||
|
),
|
||||||
|
"REDIS_URL": EnvSpec(
|
||||||
|
"REDIS_URL", "connection", "string",
|
||||||
|
"Redis connection URL",
|
||||||
|
is_secret=False, is_editable=False,
|
||||||
|
),
|
||||||
|
"DATA_DIR": EnvSpec(
|
||||||
|
"DATA_DIR", "connection", "string",
|
||||||
|
"Data directory path",
|
||||||
|
is_secret=False, is_editable=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(value: str | None) -> str:
|
||||||
|
"""Mask a secret to **** + last 4 chars (or **** if shorter)."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if len(value) <= 4:
|
||||||
|
return "****"
|
||||||
|
return "****" + value[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def coerce(spec: EnvSpec, raw: Any) -> Any:
|
||||||
|
"""Coerce raw input (str from JSON) to typed value, with validation.
|
||||||
|
|
||||||
|
Raises ValueError on invalid input.
|
||||||
|
"""
|
||||||
|
if raw is None or raw == "":
|
||||||
|
raise ValueError("ערך ריק")
|
||||||
|
if spec.type == "bool":
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
s = str(raw).strip().lower()
|
||||||
|
if s in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if s in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
raise ValueError(f"ערך bool לא חוקי: {raw}")
|
||||||
|
if spec.type == "int":
|
||||||
|
if isinstance(raw, float) and not raw.is_integer():
|
||||||
|
raise ValueError(f"ערך int לא חוקי (שבר עשרוני): {raw}")
|
||||||
|
try:
|
||||||
|
v = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"ערך int לא חוקי: {raw}")
|
||||||
|
if spec.min is not None and v < spec.min:
|
||||||
|
raise ValueError(f"ערך {v} מתחת למינימום {spec.min}")
|
||||||
|
if spec.max is not None and v > spec.max:
|
||||||
|
raise ValueError(f"ערך {v} מעל המקסימום {spec.max}")
|
||||||
|
return v
|
||||||
|
if spec.type == "float":
|
||||||
|
try:
|
||||||
|
v = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"ערך float לא חוקי: {raw}")
|
||||||
|
if spec.min is not None and v < spec.min:
|
||||||
|
raise ValueError(f"ערך {v} מתחת למינימום {spec.min}")
|
||||||
|
if spec.max is not None and v > spec.max:
|
||||||
|
raise ValueError(f"ערך {v} מעל המקסימום {spec.max}")
|
||||||
|
return v
|
||||||
|
# string
|
||||||
|
s = str(raw)
|
||||||
|
if spec.enum_values and s not in spec.enum_values:
|
||||||
|
raise ValueError(f"ערך לא ברשימה: {spec.enum_values}")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_for_compare(spec: EnvSpec, raw: str | None) -> str | None:
|
||||||
|
"""Normalize a raw env string to a canonical form for drift comparison."""
|
||||||
|
if not raw: # None or ""
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = coerce(spec, raw)
|
||||||
|
except ValueError:
|
||||||
|
return raw # invalid value — compare as-is, drift will surface
|
||||||
|
if spec.type == "bool":
|
||||||
|
return "true" if v else "false"
|
||||||
|
return str(v)
|
||||||
57
web/mcp_introspection.py
Normal file
57
web/mcp_introspection.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# web/mcp_introspection.py
|
||||||
|
"""Introspect MCP tools from the FastMCP instance."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_mcp_tools() -> list[dict[str, Any]]:
|
||||||
|
"""List all registered MCP tools with metadata."""
|
||||||
|
from legal_mcp.server import mcp
|
||||||
|
|
||||||
|
tools = await mcp.list_tools()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for t in tools:
|
||||||
|
# Resolve underlying callable for source location
|
||||||
|
fn = _resolve_callable(t.name)
|
||||||
|
source_location = ""
|
||||||
|
module = ""
|
||||||
|
if fn is not None:
|
||||||
|
try:
|
||||||
|
file = inspect.getfile(fn)
|
||||||
|
_, line = inspect.getsourcelines(fn)
|
||||||
|
source_location = f"{file}:{line}"
|
||||||
|
module = fn.__module__
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"tool_source_resolution_failed name=%s err=%s", t.name, e,
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description or "",
|
||||||
|
"params_schema": getattr(t, "inputSchema", None),
|
||||||
|
"module": module,
|
||||||
|
"source_location": source_location,
|
||||||
|
})
|
||||||
|
return sorted(out, key=lambda r: (r["module"], r["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_callable(tool_name: str):
|
||||||
|
"""Find the python function backing a registered tool name."""
|
||||||
|
from legal_mcp.tools import (
|
||||||
|
cases, documents, drafting, precedent_library,
|
||||||
|
precedents, search, workflow,
|
||||||
|
)
|
||||||
|
for mod in (
|
||||||
|
cases, documents, drafting, precedent_library,
|
||||||
|
precedents, search, workflow,
|
||||||
|
):
|
||||||
|
fn = getattr(mod, tool_name, None)
|
||||||
|
if callable(fn):
|
||||||
|
return fn
|
||||||
|
return None
|
||||||
106
web/mcp_registrations.py
Normal file
106
web/mcp_registrations.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# web/mcp_registrations.py
|
||||||
|
"""Read MCP server registrations from host config files mounted in /host."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HOST_DIR = Path("/host")
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_env_keys(env: dict[str, Any] | None) -> list[str]:
|
||||||
|
if not env or not isinstance(env, dict):
|
||||||
|
return []
|
||||||
|
return sorted(env.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _read_claude_registrations() -> list[dict[str, Any]]:
|
||||||
|
"""Read MCP registrations from /host/.claude.json."""
|
||||||
|
path = HOST_DIR / ".claude.json"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("claude_json_parse_failed: %s", e)
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
# Top-level mcpServers
|
||||||
|
for name, cfg in (data.get("mcpServers") or {}).items():
|
||||||
|
out.append(_normalize("Claude Code (global)", name, cfg))
|
||||||
|
# Per-project mcpServers
|
||||||
|
for project_path, project_cfg in (data.get("projects") or {}).items():
|
||||||
|
for name, cfg in (project_cfg.get("mcpServers") or {}).items():
|
||||||
|
out.append(_normalize(f"Claude Code ({project_path})", name, cfg))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _read_paperclip_registrations() -> list[dict[str, Any]]:
|
||||||
|
"""Read MCP registrations from /host/.paperclip/instances/*/mcp.json."""
|
||||||
|
base = HOST_DIR / ".paperclip" / "instances"
|
||||||
|
if not base.exists():
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for instance_dir in sorted(base.iterdir()):
|
||||||
|
if not instance_dir.is_dir():
|
||||||
|
continue
|
||||||
|
mcp_json = instance_dir / "mcp.json"
|
||||||
|
if not mcp_json.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(mcp_json.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"paperclip_mcp_json_parse_failed: %s %s",
|
||||||
|
instance_dir.name, e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
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))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(client: str, server_name: str, cfg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"client": client,
|
||||||
|
"server_name": server_name,
|
||||||
|
"command": cfg.get("command", ""),
|
||||||
|
"args": cfg.get("args") or [],
|
||||||
|
"cwd": cfg.get("cwd") or cfg.get("workingDirectory") or "",
|
||||||
|
"env_keys": _redact_env_keys(cfg.get("env")),
|
||||||
|
"transport": cfg.get("transport") or "stdio",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_registrations() -> dict[str, Any]:
|
||||||
|
"""Return all MCP registrations + status."""
|
||||||
|
if not HOST_DIR.exists():
|
||||||
|
return {
|
||||||
|
"registrations": [],
|
||||||
|
"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": registrations,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user