feat: replace backend/slot toggle with role selector

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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-28 19:23:18 -04:00
parent 962d58d2e2
commit 8baab874f1
3 changed files with 75 additions and 50 deletions

View File

@@ -33,12 +33,12 @@ def _backend_label(backend: str, username: str, role: str = "chat") -> str:
return backend.title() return backend.title()
def _resolve_slot_label(username: str, slot: str) -> str | None: def _role_model_label(username: str, role: str, actual_backend: str) -> str:
"""Return the configured model label for a chat role slot, or None.""" """Return the model label for a role, falling back to the generic backend label."""
cfg = model_registry.get_model_for_slot(username, "chat", slot) cfg = model_registry.get_model_for_role(username, role)
if cfg: if cfg:
return cfg.get("label") or cfg.get("model_name") return cfg.get("label") or cfg.get("model_name") or _backend_label(actual_backend, username, role)
return None return _backend_label(actual_backend, username, role)
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
@@ -46,7 +46,7 @@ class ChatRequest(BaseModel):
session_id: str | None = None session_id: str | None = None
tier: int | None = None tier: int | None = None
model: str | None = None # legacy backend override ("claude"|"gemini"|"local") model: str | None = None # legacy backend override ("claude"|"gemini"|"local")
slot: str | None = None # Phase 3: role slot ("primary"|"backup_1"|"backup_2") chat_role: str = "chat" # active role: "chat"|"coder"|"research"|"distill" etc.
include_long: bool = True include_long: bool = True
include_mid: bool = True include_mid: bool = True
include_short: bool = True include_short: bool = True
@@ -103,7 +103,7 @@ async def _stream_chat(req: ChatRequest):
system_prompt=system_prompt, system_prompt=system_prompt,
messages=history, messages=history,
model=req.model, model=req.model,
slot=req.slot, role=req.chat_role,
)) ))
try: try:
@@ -119,11 +119,7 @@ async def _stream_chat(req: ChatRequest):
try: try:
response_text, actual_backend = task.result() response_text, actual_backend = task.result()
# Use the slot's model label when a slot was pinned; fall back to generic label backend_label = _role_model_label(user, req.chat_role, actual_backend)
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")
host = platform.node() host = platform.node()
history.append({ history.append({
"role": "assistant", "role": "assistant",
@@ -201,20 +197,26 @@ def _local_model_info(request: Request) -> dict | None:
return None return None
def _chat_models_for_toggle(username: str) -> list[dict]: def _available_roles_for_toggle(username: str) -> list[dict]:
"""Return non-empty chat role slots as [{slot, label, type}] for the UI toggle.""" """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) registry = model_registry.get_registry(username)
role_cfg = registry.get("roles", {}).get("chat", {}) roles_cfg = registry.get("roles", {})
result = [] result = []
for slot in model_registry.PRIORITY_KEYS[:3]: for role_name in settings.get_defined_roles():
model_id = role_cfg.get(slot) if role_name == "orchestrator":
if not model_id:
continue 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: if resolved:
result.append({ result.append({
"slot": slot, "role": role_name,
"label": resolved.get("label") or resolved.get("model_name") or slot, "label": role_name.title(),
"model_label": resolved.get("label") or resolved.get("model_name") or "",
"type": resolved.get("type", ""), "type": resolved.get("type", ""),
}) })
return result return result
@@ -223,10 +225,10 @@ def _chat_models_for_toggle(username: str) -> list[dict]:
@router.get("/backend") @router.get("/backend")
async def get_backend(request: Request) -> dict: async def get_backend(request: Request) -> dict:
username = _request_user(request) username = _request_user(request)
chat_models = _chat_models_for_toggle(username) if username else [] available_roles = _available_roles_for_toggle(username) if username else []
p = settings.primary_backend p = settings.primary_backend
return { return {
"chat_models": chat_models, "available_roles": available_roles,
# Legacy fields kept for backward compat # Legacy fields kept for backward compat
"primary": p, "primary": p,
"fallback": _BACKEND_FALLBACK.get(p, "claude"), "fallback": _BACKEND_FALLBACK.get(p, "claude"),

View File

@@ -339,49 +339,48 @@
document.addEventListener('click', () => personaDropEl.classList.remove('open')); document.addEventListener('click', () => personaDropEl.classList.remove('open'));
} }
// ── Backend toggle ─────────────────────────────────────────── // ── Role toggle ──────────────────────────────────────────────
// Phase 3: cycles through the chat role's configured models by label. // Cycles through roles that have a primary model assigned (excluding orchestrator).
// Sends slot ("primary"|"backup_1"|"backup_2") in chat requests. // Sends chat_role ("chat"|"coder"|"research"|...) in chat requests.
// Falls back to legacy "auto" behavior when no models are configured. // 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 TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' };
const backendModelHint = document.getElementById('backend-model-hint'); const backendModelHint = document.getElementById('backend-model-hint');
let chatSlots = []; // [{slot, label, type}] from /backend let availableRoles = []; // [{role, label, model_label, type}] from /backend
let slotIdx = 0; // index into chatSlots; -1 = auto (no registry models) let roleIdx = 0;
function activeSlot() { function activeRole() {
return chatSlots.length > 0 ? chatSlots[slotIdx] : null; return availableRoles.length > 0 ? availableRoles[roleIdx] : null;
} }
function setToggleUI(entry) { function setRoleToggleUI(entry) {
if (!entry) { if (!entry) {
backendToggle.textContent = 'auto'; backendToggle.textContent = 'chat';
backendToggle.className = 'ctx-btn'; backendToggle.className = 'ctx-btn';
primaryBackend = null;
} else { } else {
backendToggle.textContent = entry.label; backendToggle.textContent = entry.label;
backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || ''); backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || '');
primaryBackend = entry.slot; // used as legacy compat in payload
} }
if (backendModelHint) { if (backendModelHint) {
backendModelHint.textContent = ''; const hint = entry?.model_label || '';
backendModelHint.style.display = 'none'; backendModelHint.textContent = hint;
backendModelHint.style.display = hint ? '' : 'none';
} }
} }
fetch('/backend').then(r => r.json()).then(d => { fetch('/backend').then(r => r.json()).then(d => {
chatSlots = d.chat_models || []; availableRoles = d.available_roles || [];
slotIdx = 0; roleIdx = 0;
setToggleUI(chatSlots[0] || null); setRoleToggleUI(availableRoles[0] || null);
}); });
backendToggle.addEventListener('click', () => { backendToggle.addEventListener('click', () => {
if (chatSlots.length === 0) return; if (availableRoles.length <= 1) return;
slotIdx = (slotIdx + 1) % chatSlots.length; roleIdx = (roleIdx + 1) % availableRoles.length;
const entry = chatSlots[slotIdx]; const entry = availableRoles[roleIdx];
setToggleUI(entry); setRoleToggleUI(entry);
addMessage('system', `Backend: ${entry.label}`); addMessage('system', `Role: ${entry.label} · ${entry.model_label}`);
}); });
// ── Sessions panel ─────────────────────────────────────────── // ── Sessions panel ───────────────────────────────────────────
@@ -1056,7 +1055,7 @@
include_mid: memMid, include_mid: memMid,
include_short: memShort, include_short: memShort,
off_record: current_mode === 'otr', off_record: current_mode === 'otr',
slot: activeSlot()?.slot || null, chat_role: activeRole()?.role || 'chat',
user: CORTEX_USER, user: CORTEX_USER,
persona: CORTEX_PERSONA, persona: CORTEX_PERSONA,
}; };

View File

@@ -269,6 +269,24 @@
</p> </p>
</div> </div>
<!-- Browser cache -->
<div class="section">
<h2>Browser Cache</h2>
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;">
Clears UI preferences stored in this browser: active mode, session ID, memory toggles,
theme, font size, and context tier. Does not sign you out.
</p>
<button type="button" id="clear-ls-btn"
style="padding:0.5rem 1rem; background:none; border:1px solid #2d3148; border-radius:6px;
color:#94a3b8; font-size:0.88rem; font-weight:500; cursor:pointer;
transition:border-color 0.15s, color 0.15s;">
Clear browser cache
</button>
<span id="clear-ls-ok" style="display:none; margin-left:0.75rem; font-size:0.8rem; color:#4ade80;">
Cleared.
</span>
</div>
<!-- Model Registry link --> <!-- Model Registry link -->
<div class="section"> <div class="section">
<h2>Model Registry</h2> <h2>Model Registry</h2>
@@ -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 // Persona rename toggle
document.querySelectorAll('.persona-rename-toggle').forEach(btn => { document.querySelectorAll('.persona-rename-toggle').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {