feat: per-role inject_mode, OTR fixes, hover metadata, send/stop tooltip

- inject_mode: per-role toggle (parallel to inject_datetime) gates the
  "Current mode: Off The Record" line in the system prompt; wired through
  model_registry, context_loader, chat router, orchestrator router, and
  local_llm settings UI

- OTR orchestrator fix: OrchestrateRequest now carries off_record;
  _finalize_job stores it per message and gates log_turn on it; JS
  orchestrate payload sends off_record correctly

- Per-message hover metadata: removed always-visible .model-tag; replaced
  with .msg-meta strip in the action bar (hover-only); shows model label,
  host, fallback indicator, and OTR badge; stored in session JSON

- Send/stop button tooltip: shows role + model and (when tools on)
  separate orchestrator model + engine label; live elapsed timer on stop
  button via startRunTimer/stopRunTimer

- OrchestratorResult.backend_label: new field; openai_orchestrator fills
  it; finalize_job propagates it to job dict and session messages

- GET /backend: exposes orchestrator_model label so the frontend tooltip
  can show both models separately

- TODO: session delete confirmation added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-09 16:12:03 -04:00
parent 0afa135ce9
commit 85792a7bcf
11 changed files with 229 additions and 50 deletions

View File

@@ -279,6 +279,7 @@
? { icon: 'zap', label: 'Run' }
: sd;
sendBtn.innerHTML = icon_html(effectiveSd.icon) + ' ' + effectiveSd.label;
updateSendBtnTitle();
render_icons();
updateInputPlaceholder();
@@ -315,6 +316,8 @@
// 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).
let toolsEnabled = localStorage.getItem('tools-enabled') === 'true';
let _runStart = 0;
let _runTimer = null;
function updateToolsToggleUI() {
tools_toggle_el.classList.toggle('local-on', toolsEnabled);
@@ -331,6 +334,56 @@
updateToolsToggleUI();
});
function updateSendBtnTitle() {
const role = activeRole();
const rmodel = role?.model_label || '(server default)';
const rname = role?.label || 'Chat';
const mode = current_mode === 'otr' ? 'Off The Record'
: current_mode === 'note' ? 'Note'
: 'Chat';
const useOrch = toolsEnabled && current_mode !== 'note';
let lines;
if (useOrch) {
const omodel = orchestratorModel || '(server default)';
lines = [
`Role: ${rname} · ${rmodel}`,
`Orchestrator: ${omodel} (tool loop)`,
`Mode: ${mode}`,
];
} else {
lines = [
`Role: ${rname} · ${rmodel}`,
`Mode: ${mode}`,
`Engine: Direct (no tool loop)`,
];
}
sendBtn.title = lines.join('\n');
}
function startRunTimer() {
_runStart = Date.now();
function tick() {
const secs = Math.floor((Date.now() - _runStart) / 1000);
const role = activeRole();
const rname = role?.label || 'Chat';
const useOrch = toolsEnabled && current_mode !== 'note';
const model = useOrch
? (orchestratorModel || '(server default)') + ' (tool loop)'
: (role?.model_label || '(server default)');
stopBtn.title = `Running: ${rname} · ${model}\nElapsed: ${secs}s — click to cancel`;
}
tick();
_runTimer = setInterval(tick, 1000);
}
function stopRunTimer() {
clearInterval(_runTimer);
_runTimer = null;
stopBtn.title = '';
updateSendBtnTitle();
}
// ── Settings dropdown ─────────────────────────────────────────
settings_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
@@ -414,8 +467,9 @@
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 availableRoles = []; // [{role, label, model_label, type}] from /backend
let roleIdx = 0;
let orchestratorModel = null; // label of the orchestrator-role model
function activeRole() {
return availableRoles.length > 0 ? availableRoles[roleIdx] : null;
@@ -434,11 +488,13 @@
backendModelHint.textContent = hint;
backendModelHint.style.display = hint ? '' : 'none';
}
updateSendBtnTitle();
}
fetch('/backend').then(r => r.json()).then(d => {
availableRoles = d.available_roles || [];
roleIdx = 0;
availableRoles = d.available_roles || [];
orchestratorModel = d.orchestrator_model || null;
roleIdx = 0;
setRoleToggleUI(availableRoles[0] || null);
_maybeShowNoBanner(availableRoles);
});
@@ -686,13 +742,11 @@
currentHistory.push({ role, content: msg.content });
const msgDiv = addMessage(role, msg.content);
attachHistoryControls(msgDiv, i);
if (role === 'assistant' && (msg.backend_label || msg.backend)) {
const modelTag = document.createElement('div');
modelTag.className = 'model-tag';
const label = msg.backend_label || msg.backend;
modelTag.textContent = msg.host ? `${label} · ${msg.host}` : label;
msgDiv.appendChild(modelTag);
}
setMessageMeta(msgDiv, {
label: (role === 'assistant') ? (msg.backend_label || msg.backend || '') : '',
host: msg.host || '',
otr: !!msg.off_record,
});
}
if (!silent) addMessage('system', `Resumed session: ${displayName}`);
@@ -703,6 +757,37 @@
persist_session();
}
// ── Message metadata (hover bar) ─────────────────────────────
function setMessageMeta(msgDiv, {label = '', host = '', fallback = false, otr = false} = {}) {
const wrapper = msgDiv.closest ? msgDiv.closest('.msg-wrapper') : msgDiv.parentElement;
if (!wrapper) return;
const actionsDiv = wrapper.querySelector('.msg-actions');
if (!actionsDiv) return;
const existing = actionsDiv.querySelector('.msg-meta');
if (existing) existing.remove();
if (!label && !otr) return;
const meta = document.createElement('span');
meta.className = 'msg-meta';
if (label) {
const modelSpan = document.createElement('span');
modelSpan.className = 'msg-meta-model' + (fallback ? ' fallback' : '');
modelSpan.textContent = (fallback ? '⚡ ' : '') + label + (host ? ' · ' + host : '');
meta.appendChild(modelSpan);
}
if (otr) {
const badge = document.createElement('span');
badge.className = 'msg-meta-badge otr';
badge.textContent = 'OTR';
meta.appendChild(badge);
}
actionsDiv.insertBefore(meta, actionsDiv.firstChild);
}
function timeAgo(iso) {
if (!iso) return '?';
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
@@ -1115,15 +1200,12 @@
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
// Model tag — always shown, amber if fallback was used
const modelTag = document.createElement('div');
modelTag.className = 'model-tag' + (data.fallback_used ? ' fallback' : '');
const label = data.backend_label || data.backend || '';
const hostSuffix = data.host ? ` · ${data.host}` : '';
modelTag.textContent = data.fallback_used
? `⚡ fallback → ${label}${hostSuffix}`
: `${label}${hostSuffix}`;
thinkingDiv.appendChild(modelTag);
setMessageMeta(thinkingDiv, {
label: data.backend_label || data.backend || '',
host: data.host || '',
fallback: !!data.fallback_used,
otr: current_mode === 'otr',
});
} else if (data.type === 'error') {
throw new Error(data.message);
}
@@ -1157,6 +1239,7 @@
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
startRunTimer();
await _doSend(payload, thinkingDiv);
@@ -1164,6 +1247,7 @@
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
@@ -1182,13 +1266,17 @@
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
startRunTimer();
activeController = new AbortController();
const isOtr = current_mode === 'otr';
const userHistIdx = currentHistory.length;
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
attachHistoryControls(userMsgDiv, userHistIdx);
if (isOtr) setMessageMeta(userMsgDiv, {otr: true});
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
@@ -1200,7 +1288,7 @@
include_long: memLong,
include_mid: memMid,
include_short: memShort,
off_record: current_mode === 'otr',
off_record: isOtr,
chat_role: activeRole()?.role || 'chat',
user: CORTEX_USER,
persona: CORTEX_PERSONA,
@@ -1212,12 +1300,14 @@
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
}
// Extracted so the retry button can call it without re-adding the
// user message to the DOM or currentHistory.
async function _doOrchestrate(text, thinkingDiv, userMsgDiv) {
const submitOtr = current_mode === 'otr';
try {
const res = await fetch('/orchestrate', {
method: 'POST',
@@ -1229,6 +1319,7 @@
include_long: memLong,
include_mid: memMid,
include_short: memShort,
off_record: current_mode === 'otr',
chat_role: activeRole()?.role || 'chat',
user: CORTEX_USER,
persona: CORTEX_PERSONA,
@@ -1312,6 +1403,12 @@
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: job.response || '' });
attachHistoryControls(thinkingDiv, assistHistIdx);
setMessageMeta(thinkingDiv, {
label: job.backend_label || job.backend || '',
host: job.host || '',
otr: submitOtr,
});
if (submitOtr) setMessageMeta(userMsgDiv, {otr: true});
renderToolCalls(job.tool_calls, thinkingDiv.parentElement);
@@ -1341,6 +1438,7 @@
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
startRunTimer();
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
@@ -1348,6 +1446,7 @@
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
@@ -1364,6 +1463,7 @@
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
startRunTimer();
activeController = new AbortController();
@@ -1379,6 +1479,7 @@
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
}

View File

@@ -634,6 +634,9 @@
// Inject datetime checkbox (default true if not set)
const dtCb = panel.querySelector('.rcp-datetime-cb');
if (dtCb) dtCb.checked = cfg.inject_datetime !== false;
// Inject mode checkbox (default true if not set)
const modeCb = panel.querySelector('.rcp-mode-cb');
if (modeCb) modeCb.checked = cfg.inject_mode !== false;
// Build tool checklist
buildToolChecklist(role, cfg.tools || null);
panel.classList.add('open');
@@ -674,6 +677,8 @@
const ta = panel.querySelector('.rcp-textarea');
const dtCb = panel.querySelector('.rcp-datetime-cb');
const inject_datetime = dtCb ? dtCb.checked : true;
const modeCb = panel.querySelector('.rcp-mode-cb');
const inject_mode = modeCb ? modeCb.checked : true;
const checks = [...panel.querySelectorAll('.rcp-tools input[type=checkbox]')];
const allChecked = checks.every(c => c.checked);
const someChecked = checks.some(c => c.checked);
@@ -684,7 +689,7 @@
const res = await fetch('/api/models/role-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role, system_append: ta.value, tools, inject_datetime}),
body: JSON.stringify({role, system_append: ta.value, tools, inject_datetime, inject_mode}),
});
const data = await res.json();
if (data.ok) {
@@ -693,6 +698,7 @@
ROLE_CONFIG_DATA[role].system_append = ta.value;
ROLE_CONFIG_DATA[role].tools = tools;
ROLE_CONFIG_DATA[role].inject_datetime = inject_datetime;
ROLE_CONFIG_DATA[role].inject_mode = inject_mode;
showToast(`${role} config saved`);
closeRolePanel(role);
} else {

View File

@@ -614,18 +614,34 @@
.copy-btn:hover { color: var(--text); border-color: var(--muted); }
.copy-btn.copied { color: var(--success); border-color: var(--success-dim); }
/* Model tag — shown at the bottom of every assistant message */
.model-tag {
display: block;
font-size: 0.67rem;
color: #475569;
margin-top: 0.55rem;
padding-top: 0.4rem;
border-top: 1px solid #2d3148;
text-align: right;
/* Message metadata — shown in the hover bar below the bubble */
.msg-meta {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
font-size: 0.62rem;
color: var(--dim);
letter-spacing: 0.02em;
overflow: hidden;
}
.model-tag.fallback { color: #f59e0b; }
.msg-meta-model {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-meta-model.fallback { color: #f59e0b; }
.msg-meta-badge {
flex-shrink: 0;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.msg-meta-badge.otr { background: #1e1b4b; color: #818cf8; }
[data-theme="light"] .msg-meta-badge.otr { background: #ede9fe; color: #5b21b6; }
/* Retry button — shown in error message bubbles */
.retry-btn {