feat: Phase 3 model toggle — cycle chat-role slot models in UI

Replaces the role-cycle toggle with a slot model toggle in the Context &
Memory panel. The active model label is shown on the button; clicking cycles
through Primary → Backup 1 → Backup 2 slots configured for the Chat role.

- app.js: remove activeRole()/availableRoles role-cycling; add
  activeChatModel()/chatModels slot cycling; update send/orchestrate
  payloads to send slot + chat_role:"chat"; fix updateSendBtnTitle and
  startRunTimer to use activeChatModel()
- chat.py: add slot field to ChatRequest; pass slot= to complete();
  resolve backend_label from slot config; add _chat_slot_models() helper;
  include chat_models in GET /backend response
- HELP.md: update Model toggle description, tool count (62/16),
  Backends section, API chat payload example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-12 21:32:43 -04:00
parent 85e13314a2
commit 3716e5974f
3 changed files with 83 additions and 60 deletions

View File

@@ -47,6 +47,7 @@ class ChatRequest(BaseModel):
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: explicit slot ("primary"|"backup_1"|"backup_2")
chat_role: str = "chat" # active role: "chat"|"coder"|"research"|"distill" etc.
include_long: bool = True
include_mid: bool = True
@@ -109,6 +110,7 @@ async def _stream_chat(req: ChatRequest):
messages=history,
model=req.model,
role=req.chat_role,
slot=req.slot,
))
try:
@@ -124,7 +126,11 @@ async def _stream_chat(req: ChatRequest):
try:
response_text, actual_backend = task.result()
backend_label = _role_model_label(user, req.chat_role, actual_backend)
if req.slot:
slot_cfg = model_registry.get_model_for_slot(user, req.chat_role, req.slot)
backend_label = (slot_cfg or {}).get("label") or _role_model_label(user, req.chat_role, actual_backend)
else:
backend_label = _role_model_label(user, req.chat_role, actual_backend)
host = platform.node()
history.append({
"role": "assistant",
@@ -203,6 +209,25 @@ def _local_model_info(request: Request) -> dict | None:
return None
def _chat_slot_models(username: str) -> list[dict]:
"""Return [{slot, label, type}] for each configured slot in the chat role, primary first."""
registry = model_registry.get_registry(username)
role_slots = registry.get("roles", {}).get("chat", {})
result = []
for slot_key in model_registry.PRIORITY_KEYS:
model_id = role_slots.get(slot_key)
if not model_id:
continue
resolved = model_registry._resolve_model(registry, model_id)
if resolved:
result.append({
"slot": slot_key,
"label": resolved.get("label") or resolved.get("model_name") or "",
"type": resolved.get("type", ""),
})
return result
def _available_roles_for_toggle(username: str) -> list[dict]:
"""Return roles with a primary model assigned (excluding orchestrator) for the UI toggle.
@@ -231,6 +256,7 @@ def _available_roles_for_toggle(username: str) -> list[dict]:
@router.get("/backend")
async def get_backend(request: Request) -> dict:
username = _request_user(request)
chat_models = _chat_slot_models(username) if username else []
available_roles = _available_roles_for_toggle(username) if username else []
p = settings.primary_backend
@@ -241,7 +267,8 @@ async def get_backend(request: Request) -> dict:
orch_label = orch_cfg.get("label") or orch_cfg.get("model_name") or None
return {
"available_roles": available_roles,
"chat_models": chat_models, # Phase 3: [{slot, label, type}] for chat-role slots
"available_roles": available_roles, # kept for banner + backward compat
"orchestrator_model": orch_label,
# Legacy fields kept for backward compat
"primary": p,