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

@@ -313,8 +313,8 @@
});
// ── Tools toggle ─────────────────────────────────────────────
// When on: submit goes to POST /orchestrate (Gemini tool loop → active role responds).
// When off: submit goes to POST /chat (direct to active role, no tools).
// When on: submit goes to POST /orchestrate (orchestrator tool loop → active model responds).
// When off: submit goes to POST /chat (direct to active model, no tools).
let toolsEnabled = localStorage.getItem('tools-enabled') === 'true';
let _runStart = 0;
let _runTimer = null;
@@ -335,9 +335,8 @@
});
function updateSendBtnTitle() {
const role = activeRole();
const rmodel = role?.model_label || '(server default)';
const rname = role?.label || 'Chat';
const entry = activeChatModel();
const rmodel = entry?.label || '(server default)';
const mode = current_mode === 'otr' ? 'Off The Record'
: current_mode === 'note' ? 'Note'
: 'Chat';
@@ -347,13 +346,13 @@
if (useOrch) {
const omodel = orchestratorModel || '(server default)';
lines = [
`Role: ${rname} · ${rmodel}`,
`Model: ${rmodel}`,
`Orchestrator: ${omodel} (tool loop)`,
`Mode: ${mode}`,
];
} else {
lines = [
`Role: ${rname} · ${rmodel}`,
`Model: ${rmodel}`,
`Mode: ${mode}`,
`Engine: Direct (no tool loop)`,
];
@@ -364,14 +363,13 @@
function startRunTimer() {
_runStart = Date.now();
function tick() {
const secs = Math.floor((Date.now() - _runStart) / 1000);
const role = activeRole();
const rname = role?.label || 'Chat';
const secs = Math.floor((Date.now() - _runStart) / 1000);
const entry = activeChatModel();
const useOrch = toolsEnabled && current_mode !== 'note';
const model = useOrch
const model = useOrch
? (orchestratorModel || '(server default)') + ' (tool loop)'
: (role?.model_label || '(server default)');
stopBtn.title = `Running: ${rname} · ${model}\nElapsed: ${secs}s — click to cancel`;
: (entry?.label || '(server default)');
stopBtn.title = `Running: Chat · ${model}\nElapsed: ${secs}s — click to cancel`;
}
tick();
_runTimer = setInterval(tick, 1000);
@@ -469,23 +467,24 @@
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
}
// ── 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.
// ── Model toggle (Phase 3) ───────────────────────────────────
// Cycles through the chat role's configured slot models (primary → backup_1 → …).
// Shows the model label on the button; sends slot + chat_role:"chat" in requests.
// Falls back to "chat" / no slot when no models are configured.
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 availableRoles = []; // [{role, label, model_label, type}] from /backend
let roleIdx = 0;
let orchestratorModel = null; // label of the orchestrator-role model
let chatModels = []; // [{slot, label, type}] for chat-role slots
let availableRoles = []; // [{role, label, model_label, type}] — kept for banner check
let modelIdx = 0;
let orchestratorModel = null;
function activeRole() {
return availableRoles.length > 0 ? availableRoles[roleIdx] : null;
function activeChatModel() {
return chatModels.length > 0 ? chatModels[modelIdx] : null;
}
function setRoleToggleUI(entry) {
function setModelToggleUI(entry) {
if (!entry) {
backendToggle.textContent = 'chat';
backendToggle.className = 'ctx-btn';
@@ -493,19 +492,16 @@
backendToggle.textContent = entry.label;
backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || '');
}
if (backendModelHint) {
const hint = entry?.model_label || '';
backendModelHint.textContent = hint;
backendModelHint.style.display = hint ? '' : 'none';
}
if (backendModelHint) backendModelHint.style.display = 'none';
updateSendBtnTitle();
}
fetch('/backend').then(r => r.json()).then(d => {
chatModels = d.chat_models || [];
availableRoles = d.available_roles || [];
orchestratorModel = d.orchestrator_model || null;
roleIdx = 0;
setRoleToggleUI(availableRoles[0] || null);
modelIdx = 0;
setModelToggleUI(chatModels[0] || null);
_maybeShowNoBanner(availableRoles);
});
@@ -527,17 +523,16 @@
style="background:none;border:none;color:#78350f;cursor:pointer;font-size:1rem;line-height:1;padding:0 0.2rem;"
title="Dismiss">✕</button>
`;
// Insert at the top of #chat-col (or body if not found)
const col = document.getElementById('chat-col') || document.body.firstElementChild;
col.insertBefore(banner, col.firstChild);
}
backendToggle.addEventListener('click', () => {
if (availableRoles.length <= 1) return;
roleIdx = (roleIdx + 1) % availableRoles.length;
const entry = availableRoles[roleIdx];
setRoleToggleUI(entry);
addMessage('system', `Role: ${entry.label} · ${entry.model_label}`);
if (chatModels.length <= 1) return;
modelIdx = (modelIdx + 1) % chatModels.length;
const entry = chatModels[modelIdx];
setModelToggleUI(entry);
addMessage('system', `Model: ${entry.label}`);
});
// ── Sessions panel ───────────────────────────────────────────
@@ -1346,7 +1341,8 @@
include_mid: memMid,
include_short: memShort,
off_record: isOtr,
chat_role: activeRole()?.role || 'chat',
chat_role: 'chat',
slot: activeChatModel()?.slot || null,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
};
@@ -1377,7 +1373,8 @@
include_mid: memMid,
include_short: memShort,
off_record: current_mode === 'otr',
chat_role: activeRole()?.role || 'chat',
chat_role: 'chat',
slot: activeChatModel()?.slot || null,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
}),