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.
+