diff --git a/cortex/model_registry.py b/cortex/model_registry.py index fe2ea03..b47d021 100644 --- a/cortex/model_registry.py +++ b/cortex/model_registry.py @@ -415,6 +415,24 @@ def get_best_local_model(username: str, role: str = "chat") -> dict | None: return None +def set_role_config(username: str, role: str, system_append: str, tools: list[str] | None) -> None: + """Save system_append and tools allow-list for a role. + + tools=None clears the allow-list (role uses all accessible tools). + tools=[] would mean no tools at all — validate in the caller if that's undesired. + """ + data = _load(username) + roles = data.setdefault("roles", {}) + if role not in roles: + roles[role] = {} + roles[role]["system_append"] = system_append.strip() + if tools is None: + roles[role].pop("tools", None) + else: + roles[role]["tools"] = [t for t in tools if t] + _save(username, data) + + def get_role_config(username: str, role: str) -> dict: """ Return supplemental config for a role: system_append and tools. diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index b2d55d4..4475b39 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -26,6 +26,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from auth_utils import COOKIE_NAME, decode_token from config import settings as app_settings import model_registry as reg +from tools import TOOL_CATEGORIES logger = logging.getLogger(__name__) router = APIRouter() @@ -285,13 +286,42 @@ def _render(username: str, success: str = "", error: str = "") -> str: f'data-slot="{slot}" title="{slot_label}">\n{model_opts}\n' ) role_rows += f'
{slot_label}{sel}
' - role_rows += '' + role_rows += ( + f'' + f'' + f'' + f'
' + f'
' + f'' + f'' + f'
' + f'
' + f'' + f'
' + f'
' + f'
' + f'' + f'' + f'
' + f'
' + ) role_data_js = _json.dumps({ role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]} for role in app_settings.get_defined_roles() }) + role_config_data_js = _json.dumps({ + role: { + "system_append": roles.get(role, {}).get("system_append", ""), + "tools": roles.get(role, {}).get("tools") or None, + } + for role in app_settings.get_defined_roles() + }) + tool_categories_js = _json.dumps(TOOL_CATEGORIES) + # ── Catalog data + Google accounts for JS ───────────────────────────────── google_accounts_js = _json.dumps(reg.get_google_accounts(username)) google_catalog_js = _json.dumps(reg.get_catalog("google")) @@ -305,8 +335,10 @@ def _render(username: str, success: str = "", error: str = "") -> str: "{{ host_rows }}": host_rows, "{{ model_rows }}": model_rows, "{{ host_options }}": host_options, - "{{ role_rows }}": role_rows, - "{{ role_data_js }}": role_data_js, + "{{ role_rows }}": role_rows, + "{{ role_data_js }}": role_data_js, + "{{ role_config_data_js }}": role_config_data_js, + "{{ tool_categories_js }}": tool_categories_js, "{{ google_accounts_js }}": google_accounts_js, "{{ google_catalog_js }}": google_catalog_js, "{{ anthropic_catalog_js }}": anthropic_catalog_js, @@ -510,6 +542,36 @@ async def set_role(request: Request) -> JSONResponse: return JSONResponse({"ok": True}) +@router.post("/api/models/role-config") +async def set_role_config(request: Request) -> JSONResponse: + """AJAX: save system_append and tool allow-list for a role. + + Body: {"role": "coder", "system_append": "...", "tools": ["web_search", ...] | null} + tools=null clears the allow-list (role uses all accessible tools). + """ + username = _get_user(request) + if not username: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + role = body.get("role", "").strip() + system_append = body.get("system_append", "") + tools = body.get("tools") # list[str] or None + + if not role: + return JSONResponse({"error": "role is required"}, status_code=400) + if tools is not None and not isinstance(tools, list): + return JSONResponse({"error": "tools must be a list or null"}, status_code=400) + + reg.set_role_config(username, role, system_append, tools) + logger.info("role config saved: %s %s (tools=%s)", username, role, + len(tools) if tools is not None else "all") + return JSONResponse({"ok": True}) + + @router.get("/api/local-llm/fetch-models") async def fetch_models(request: Request, host_id: str = "") -> JSONResponse: """Proxy to the host's models endpoint. host_id selects which host.""" diff --git a/cortex/static/TOOLS.md b/cortex/static/TOOLS.md index 7c4c29b..ee743bf 100644 --- a/cortex/static/TOOLS.md +++ b/cortex/static/TOOLS.md @@ -1,6 +1,6 @@ # Tool Reference -> This reference covers all 30 orchestrator tools available when the ⚡ toggle is on. +> This reference covers all 39 orchestrator tools available when the ⚡ toggle is on. > Tools are invoked automatically by the orchestrator — you don't call them directly. ¹ **Admin only** — requires the `admin` role. Invisible to regular users. diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index 77e1323..5431e60 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -223,7 +223,6 @@ display: flex; align-items: flex-start; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--pg-border-deep); } - .role-row:last-child { border-bottom: none; } .role-name { font-size: 0.82rem; font-weight: 600; color: #a78bfa; min-width: 6rem; padding-top: 0.45rem; } .role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; } .role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; } @@ -238,6 +237,36 @@ .role-select.saved { border-color: #166534; } .role-select.saving { border-color: #92400e; } .role-select.err { border-color: #7f1d1d; } + .role-cfg-btn { + flex-shrink: 0; padding: 0.3rem 0.55rem; font-size: 0.8rem; + background: none; border: 1px solid var(--pg-border); border-radius: 6px; + color: var(--pg-dim); cursor: pointer; transition: color 0.15s, border-color 0.15s; + margin-top: 0.35rem; + } + .role-cfg-btn:hover { color: #a78bfa; border-color: #a78bfa; } + .role-cfg-btn.active { color: #a78bfa; border-color: #a78bfa; background: rgba(167,139,250,0.08); } + /* Role config panel */ + .role-config-panel { + display: none; margin: 0 0 0.75rem 7rem; + border: 1px solid var(--pg-border); border-radius: 8px; + background: var(--pg-surface); padding: 1rem; + } + .role-config-panel.open { display: block; } + .rcp-field { margin-bottom: 0.75rem; } + .rcp-label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--pg-muted); margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; } + .rcp-hint { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--pg-dimmer); } + .rcp-textarea { + width: 100%; resize: vertical; min-height: 4rem; + background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px; + color: var(--pg-text); font-family: inherit; font-size: 0.85rem; + padding: 0.5rem 0.6rem; outline: none; transition: border-color 0.15s; + } + .rcp-textarea:focus { border-color: #7c3aed; } + .rcp-tools { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; } + .rcp-cat { width: 100%; margin: 0.4rem 0 0.1rem; font-size: 0.7rem; font-weight: 600; color: var(--pg-dimmer); text-transform: uppercase; letter-spacing: 0.05em; } + .rcp-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.8rem; color: var(--pg-bright); cursor: pointer; } + .rcp-check input { accent-color: #a78bfa; cursor: pointer; } + .rcp-actions { display: flex; gap: 0.5rem; padding-top: 0.25rem; } /* Model select picker */ #model-select-wrap { display: none; margin-bottom: 0.75rem; } @@ -496,6 +525,8 @@