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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user