From 8baab874f15c20d402cee01dea8f1707c2f109b8 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 28 Apr 2026 19:23:18 -0400 Subject: [PATCH] feat: replace backend/slot toggle with role selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend toggle now cycles through configured roles (chat, coder, research, distill, etc.) instead of backup model slots within the chat role. Each role uses its own primary→backup chain from the registry. - ChatRequest.slot replaced by chat_role (default "chat") - GET /backend returns available_roles instead of chat_models - _available_roles_for_toggle() builds list from defined_roles, excluding orchestrator (which has its own Agent mode) - Model label on responses now reflects the actual role's assigned model - Toggle is inert when only one role is configured (avoids useless cycling) - Add "Clear browser cache" button to Account Settings (Connected Accounts) - Add _role_model_label() helper for cleaner response tag labeling Co-Authored-By: Claude Sonnet 4.6 --- cortex/routers/chat.py | 56 +++++++++++++++++++------------------ cortex/static/app.js | 45 +++++++++++++++-------------- cortex/static/settings.html | 24 ++++++++++++++++ 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/cortex/routers/chat.py b/cortex/routers/chat.py index 0284d1d..9938318 100644 --- a/cortex/routers/chat.py +++ b/cortex/routers/chat.py @@ -33,24 +33,24 @@ def _backend_label(backend: str, username: str, role: str = "chat") -> str: return backend.title() -def _resolve_slot_label(username: str, slot: str) -> str | None: - """Return the configured model label for a chat role slot, or None.""" - cfg = model_registry.get_model_for_slot(username, "chat", slot) +def _role_model_label(username: str, role: str, actual_backend: str) -> str: + """Return the model label for a role, falling back to the generic backend label.""" + cfg = model_registry.get_model_for_role(username, role) if cfg: - return cfg.get("label") or cfg.get("model_name") - return None + return cfg.get("label") or cfg.get("model_name") or _backend_label(actual_backend, username, role) + return _backend_label(actual_backend, username, role) class ChatRequest(BaseModel): message: str session_id: str | None = None tier: int | None = None - model: str | None = None # legacy backend override ("claude"|"gemini"|"local") - slot: str | None = None # Phase 3: role slot ("primary"|"backup_1"|"backup_2") + model: str | None = None # legacy backend override ("claude"|"gemini"|"local") + chat_role: str = "chat" # active role: "chat"|"coder"|"research"|"distill" etc. include_long: bool = True include_mid: bool = True include_short: bool = True - off_record: bool = False # skip session log (in-memory context preserved) + off_record: bool = False # skip session log (in-memory context preserved) user: str = "scott" persona: str = "inara" @@ -103,7 +103,7 @@ async def _stream_chat(req: ChatRequest): system_prompt=system_prompt, messages=history, model=req.model, - slot=req.slot, + role=req.chat_role, )) try: @@ -119,11 +119,7 @@ async def _stream_chat(req: ChatRequest): try: response_text, actual_backend = task.result() - # Use the slot's model label when a slot was pinned; fall back to generic label - if req.slot: - backend_label = _resolve_slot_label(user, req.slot) or _backend_label(actual_backend, user) - else: - backend_label = _backend_label(actual_backend, user, role="chat") + backend_label = _role_model_label(user, req.chat_role, actual_backend) host = platform.node() history.append({ "role": "assistant", @@ -201,32 +197,38 @@ def _local_model_info(request: Request) -> dict | None: return None -def _chat_models_for_toggle(username: str) -> list[dict]: - """Return non-empty chat role slots as [{slot, label, type}] for the UI toggle.""" +def _available_roles_for_toggle(username: str) -> list[dict]: + """Return roles with a primary model assigned (excluding orchestrator) for the UI toggle. + + Returns [{role, label, model_label, type}] ordered by settings.defined_roles. + """ registry = model_registry.get_registry(username) - role_cfg = registry.get("roles", {}).get("chat", {}) + roles_cfg = registry.get("roles", {}) result = [] - for slot in model_registry.PRIORITY_KEYS[:3]: - model_id = role_cfg.get(slot) - if not model_id: + for role_name in settings.get_defined_roles(): + if role_name == "orchestrator": continue - resolved = model_registry._resolve_model(registry, model_id) + primary_id = roles_cfg.get(role_name, {}).get("primary") + if not primary_id: + continue + resolved = model_registry._resolve_model(registry, primary_id) if resolved: result.append({ - "slot": slot, - "label": resolved.get("label") or resolved.get("model_name") or slot, - "type": resolved.get("type", ""), + "role": role_name, + "label": role_name.title(), + "model_label": resolved.get("label") or resolved.get("model_name") or "", + "type": resolved.get("type", ""), }) return result @router.get("/backend") async def get_backend(request: Request) -> dict: - username = _request_user(request) - chat_models = _chat_models_for_toggle(username) if username else [] + username = _request_user(request) + available_roles = _available_roles_for_toggle(username) if username else [] p = settings.primary_backend return { - "chat_models": chat_models, + "available_roles": available_roles, # Legacy fields kept for backward compat "primary": p, "fallback": _BACKEND_FALLBACK.get(p, "claude"), diff --git a/cortex/static/app.js b/cortex/static/app.js index b8b7f81..6b7e646 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -339,49 +339,48 @@ document.addEventListener('click', () => personaDropEl.classList.remove('open')); } - // ── Backend toggle ─────────────────────────────────────────── - // Phase 3: cycles through the chat role's configured models by label. - // Sends slot ("primary"|"backup_1"|"backup_2") in chat requests. - // Falls back to legacy "auto" behavior when no models are configured. + // ── Role toggle ────────────────────────────────────────────── + // Cycles through roles that have a primary model assigned (excluding orchestrator). + // Sends chat_role ("chat"|"coder"|"research"|...) in chat requests. + // Falls back to "chat" when no roles are configured in the registry. const TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' }; const backendModelHint = document.getElementById('backend-model-hint'); - let chatSlots = []; // [{slot, label, type}] from /backend - let slotIdx = 0; // index into chatSlots; -1 = auto (no registry models) + let availableRoles = []; // [{role, label, model_label, type}] from /backend + let roleIdx = 0; - function activeSlot() { - return chatSlots.length > 0 ? chatSlots[slotIdx] : null; + function activeRole() { + return availableRoles.length > 0 ? availableRoles[roleIdx] : null; } - function setToggleUI(entry) { + function setRoleToggleUI(entry) { if (!entry) { - backendToggle.textContent = 'auto'; + backendToggle.textContent = 'chat'; backendToggle.className = 'ctx-btn'; - primaryBackend = null; } else { backendToggle.textContent = entry.label; backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || ''); - primaryBackend = entry.slot; // used as legacy compat in payload } if (backendModelHint) { - backendModelHint.textContent = ''; - backendModelHint.style.display = 'none'; + const hint = entry?.model_label || ''; + backendModelHint.textContent = hint; + backendModelHint.style.display = hint ? '' : 'none'; } } fetch('/backend').then(r => r.json()).then(d => { - chatSlots = d.chat_models || []; - slotIdx = 0; - setToggleUI(chatSlots[0] || null); + availableRoles = d.available_roles || []; + roleIdx = 0; + setRoleToggleUI(availableRoles[0] || null); }); backendToggle.addEventListener('click', () => { - if (chatSlots.length === 0) return; - slotIdx = (slotIdx + 1) % chatSlots.length; - const entry = chatSlots[slotIdx]; - setToggleUI(entry); - addMessage('system', `Backend: ${entry.label}`); + if (availableRoles.length <= 1) return; + roleIdx = (roleIdx + 1) % availableRoles.length; + const entry = availableRoles[roleIdx]; + setRoleToggleUI(entry); + addMessage('system', `Role: ${entry.label} · ${entry.model_label}`); }); // ── Sessions panel ─────────────────────────────────────────── @@ -1056,7 +1055,7 @@ include_mid: memMid, include_short: memShort, off_record: current_mode === 'otr', - slot: activeSlot()?.slot || null, + chat_role: activeRole()?.role || 'chat', user: CORTEX_USER, persona: CORTEX_PERSONA, }; diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 2141534..5d2bf7b 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -269,6 +269,24 @@

+ +
+

Browser Cache

+

+ Clears UI preferences stored in this browser: active mode, session ID, memory toggles, + theme, font size, and context tier. Does not sign you out. +

+ + +
+

Model Registry

@@ -349,6 +367,12 @@ }); } + // Clear localStorage (keeps JWT cookie — no sign-out) + document.getElementById('clear-ls-btn').addEventListener('click', () => { + localStorage.clear(); + document.getElementById('clear-ls-ok').style.display = 'inline'; + }); + // Persona rename toggle document.querySelectorAll('.persona-rename-toggle').forEach(btn => { btn.addEventListener('click', () => {