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

@@ -21,6 +21,7 @@ def load_context(
include_short: bool = True, include_short: bool = True,
role_append: str = "", role_append: str = "",
inject_datetime: bool = True, inject_datetime: bool = True,
inject_mode: bool = True,
mode: str = "chat", mode: str = "chat",
) -> str: ) -> str:
""" """
@@ -45,7 +46,7 @@ def load_context(
if inject_datetime: if inject_datetime:
now = datetime.now().astimezone() now = datetime.now().astimezone()
system_lines.append(f"Current date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}") system_lines.append(f"Current date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
if mode == "otr": if mode == "otr" and inject_mode:
system_lines.append( system_lines.append(
"Current mode: Off The Record — " "Current mode: Off The Record — "
"this conversation is private and will not be logged or included in memory distillation" "this conversation is private and will not be logged or included in memory distillation"

View File

@@ -423,12 +423,13 @@ def set_role_config(
system_append: str, system_append: str,
tools: list[str] | None, tools: list[str] | None,
inject_datetime: bool = True, inject_datetime: bool = True,
inject_mode: bool = True,
) -> None: ) -> None:
"""Save system_append, tools allow-list, and inject_datetime flag for a role. """Save system_append, tools allow-list, and per-injection flags for a role.
tools=None clears the allow-list (role uses all accessible tools). tools=None clears the allow-list (role uses all accessible tools).
inject_datetime=False suppresses the current date/time from the system prompt inject_datetime=False suppresses the date/time header for pure processing roles.
for this role — useful for pure processing roles (summarizer, classifier, etc.). inject_mode=False suppresses the session mode (OTR) line for pure processing roles.
""" """
data = _load(username) data = _load(username)
roles = data.setdefault("roles", {}) roles = data.setdefault("roles", {})
@@ -436,6 +437,7 @@ def set_role_config(
roles[role] = {} roles[role] = {}
roles[role]["system_append"] = system_append.strip() roles[role]["system_append"] = system_append.strip()
roles[role]["inject_datetime"] = inject_datetime roles[role]["inject_datetime"] = inject_datetime
roles[role]["inject_mode"] = inject_mode
if tools is None: if tools is None:
roles[role].pop("tools", None) roles[role].pop("tools", None)
else: else:
@@ -445,12 +447,13 @@ def set_role_config(
def get_role_config(username: str, role: str) -> dict: def get_role_config(username: str, role: str) -> dict:
""" """
Return supplemental config for a role: system_append, tools, and inject_datetime. Return supplemental config for a role: system_append, tools, and injection flags.
All keys are optional in the registry — missing means "use defaults": All keys are optional in the registry — missing means "use defaults":
system_append: str — appended to the system prompt for this role system_append: str — appended to the system prompt for this role
tools: list[str] | None — explicit tool allow-list (None = no restriction) tools: list[str] | None — explicit tool allow-list (None = no restriction)
inject_datetime: bool — whether to inject current date/time (default True) inject_datetime: bool — whether to inject current date/time (default True)
inject_mode: bool — whether to inject session mode (OTR) line (default True)
""" """
registry = _load(username) registry = _load(username)
role_cfg = registry.get("roles", {}).get(role, {}) role_cfg = registry.get("roles", {}).get(role, {})
@@ -458,6 +461,7 @@ def get_role_config(username: str, role: str) -> dict:
"system_append": role_cfg.get("system_append", ""), "system_append": role_cfg.get("system_append", ""),
"tools": role_cfg.get("tools") or None, "tools": role_cfg.get("tools") or None,
"inject_datetime": role_cfg.get("inject_datetime", True), "inject_datetime": role_cfg.get("inject_datetime", True),
"inject_mode": role_cfg.get("inject_mode", True),
} }

View File

@@ -119,6 +119,7 @@ async def run(
response=final_response, response=final_response,
tool_calls=tool_call_log, tool_calls=tool_call_log,
backend="local", backend="local",
backend_label=model_label,
gemini_summary=final_response, gemini_summary=final_response,
) )

View File

@@ -99,6 +99,7 @@ class OrchestratorResult:
response: str # final user-facing response (from Claude) response: str # final user-facing response (from Claude)
tool_calls: list[dict] = field(default_factory=list) # [{tool, args, result}] tool_calls: list[dict] = field(default_factory=list) # [{tool, args, result}]
backend: str = "claude" # model that produced the final response backend: str = "claude" # model that produced the final response
backend_label: str = "" # human-readable model label for display
gemini_summary: str = "" # what Gemini handed to Claude (debug/display) gemini_summary: str = "" # what Gemini handed to Claude (debug/display)
checkpoint: OrchestrateCheckpoint | None = None # set when awaiting confirmation checkpoint: OrchestrateCheckpoint | None = None # set when awaiting confirmation

View File

@@ -14,6 +14,7 @@ from persona import set_context, validate as validate_persona
from auth_utils import COOKIE_NAME, decode_token from auth_utils import COOKIE_NAME, decode_token
import model_registry import model_registry
import event_bus import event_bus
from model_registry import get_role_config
router = APIRouter() router = APIRouter()
@@ -90,15 +91,18 @@ async def _stream_chat(req: ChatRequest):
session_id = req.session_id or generate_session_id() session_id = req.session_id or generate_session_id()
tier = req.tier or settings.default_tier tier = req.tier or settings.default_tier
role_cfg = get_role_config(user, req.chat_role)
system_prompt = load_context( system_prompt = load_context(
tier, tier,
include_long=req.include_long, include_long=req.include_long,
include_mid=req.include_mid, include_mid=req.include_mid,
include_short=req.include_short, include_short=req.include_short,
inject_datetime=role_cfg.get("inject_datetime", True),
inject_mode=role_cfg.get("inject_mode", True),
mode="otr" if req.off_record else "chat", mode="otr" if req.off_record else "chat",
) )
history = load_session(session_id) history = load_session(session_id)
history.append({"role": "user", "content": req.message}) history.append({"role": "user", "content": req.message, "off_record": req.off_record})
task = asyncio.create_task(complete( task = asyncio.create_task(complete(
system_prompt=system_prompt, system_prompt=system_prompt,
@@ -128,6 +132,7 @@ async def _stream_chat(req: ChatRequest):
"backend": actual_backend, "backend": actual_backend,
"backend_label": backend_label, "backend_label": backend_label,
"host": host, "host": host,
"off_record": req.off_record,
}) })
save_session(session_id, history) save_session(session_id, history)
if not req.off_record: if not req.off_record:
@@ -228,8 +233,16 @@ async def get_backend(request: Request) -> dict:
username = _request_user(request) username = _request_user(request)
available_roles = _available_roles_for_toggle(username) if username else [] available_roles = _available_roles_for_toggle(username) if username else []
p = settings.primary_backend p = settings.primary_backend
orch_label = None
if username:
orch_cfg = model_registry.get_model_for_role(username, "orchestrator")
if orch_cfg:
orch_label = orch_cfg.get("label") or orch_cfg.get("model_name") or None
return { return {
"available_roles": available_roles, "available_roles": available_roles,
"orchestrator_model": orch_label,
# 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

@@ -323,8 +323,12 @@ def _render(username: str, success: str = "", error: str = "") -> str:
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>' f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
f' Inject current date &amp; time into system prompt' f' Inject current date &amp; time into system prompt'
f'</label>' f'</label>'
f'<label class="rcp-check" style="margin-top:0.4rem">'
f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>'
f' Inject session mode (Chat / Off The Record) into system prompt'
f'</label>'
f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">' f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
f'Disable for pure processing roles (summarizer, classifier, translator)</span>' f'Disable both for pure processing roles (summarizer, classifier, translator)</span>'
f'</div>' f'</div>'
f'<div class="rcp-field">' f'<div class="rcp-field">'
f'<label class="rcp-label">Tool allow-list ' f'<label class="rcp-label">Tool allow-list '
@@ -348,6 +352,7 @@ def _render(username: str, success: str = "", error: str = "") -> str:
"system_append": roles.get(role, {}).get("system_append", ""), "system_append": roles.get(role, {}).get("system_append", ""),
"tools": roles.get(role, {}).get("tools") or None, "tools": roles.get(role, {}).get("tools") or None,
"inject_datetime": roles.get(role, {}).get("inject_datetime", True), "inject_datetime": roles.get(role, {}).get("inject_datetime", True),
"inject_mode": roles.get(role, {}).get("inject_mode", True),
} }
for role in app_settings.get_defined_roles() for role in app_settings.get_defined_roles()
}) })
@@ -607,15 +612,19 @@ async def set_role_config(request: Request) -> JSONResponse:
system_append = body.get("system_append", "") system_append = body.get("system_append", "")
tools = body.get("tools") # list[str] or None tools = body.get("tools") # list[str] or None
inject_datetime = body.get("inject_datetime", True) inject_datetime = body.get("inject_datetime", True)
inject_mode = body.get("inject_mode", True)
if not role: if not role:
return JSONResponse({"error": "role is required"}, status_code=400) return JSONResponse({"error": "role is required"}, status_code=400)
if tools is not None and not isinstance(tools, list): if tools is not None and not isinstance(tools, list):
return JSONResponse({"error": "tools must be a list or null"}, status_code=400) return JSONResponse({"error": "tools must be a list or null"}, status_code=400)
reg.set_role_config(username, role, system_append, tools, inject_datetime=bool(inject_datetime)) reg.set_role_config(username, role, system_append, tools,
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s)", inject_datetime=bool(inject_datetime),
username, role, len(tools) if tools is not None else "all", inject_datetime) inject_mode=bool(inject_mode))
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s inject_mode=%s)",
username, role, len(tools) if tools is not None else "all",
inject_datetime, inject_mode)
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})

View File

@@ -12,6 +12,7 @@ Designed to be triggered from:
import asyncio import asyncio
import logging import logging
import platform
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -57,6 +58,7 @@ class OrchestrateRequest(BaseModel):
user: str = "scott" user: str = "scott"
persona: str = "inara" persona: str = "inara"
chat_role: str = "chat" # role used for the final response (decoupled from tool-loop model) chat_role: str = "chat" # role used for the final response (decoupled from tool-loop model)
off_record: bool = False # skip session log; inject OTR mode line into system prompt
class OrchestrateResponse(BaseModel): class OrchestrateResponse(BaseModel):
@@ -74,6 +76,8 @@ class JobStatusResponse(BaseModel):
response: str | None = None response: str | None = None
tool_calls: list[dict] | None = None tool_calls: list[dict] | None = None
backend: str | None = None backend: str | None = None
backend_label: str | None = None
host: str | None = None
gemini_summary: str | None = None gemini_summary: str | None = None
error: str | None = None error: str | None = None
pending_confirmation: dict | None = None # {tools: [{name, args}], message: str} pending_confirmation: dict | None = None # {tools: [{name, args}], message: str}
@@ -109,6 +113,7 @@ async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
"error": None, "error": None,
"pending_confirmation": None, "pending_confirmation": None,
"_user": user, "_user": user,
"_off_record": req.off_record,
} }
async with _jobs_lock: async with _jobs_lock:
@@ -204,6 +209,8 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
include_short=req.include_short, include_short=req.include_short,
role_append=role_cfg.get("system_append", ""), role_append=role_cfg.get("system_append", ""),
inject_datetime=role_cfg.get("inject_datetime", True), inject_datetime=role_cfg.get("inject_datetime", True),
inject_mode=role_cfg.get("inject_mode", True),
mode="otr" if req.off_record else "chat",
) )
session_id = req.session_id or generate_session_id() session_id = req.session_id or generate_session_id()
@@ -270,7 +277,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
job_id, len(result.checkpoint.pending_tools)) job_id, len(result.checkpoint.pending_tools))
return return
await _finalize_job(job_id, result, session_id, req.task, history) await _finalize_job(job_id, result, session_id, req.task, history, off_record=req.off_record)
except Exception as e: except Exception as e:
logger.exception("Orchestrator job failed: %s", job_id) logger.exception("Orchestrator job failed: %s", job_id)
@@ -316,12 +323,13 @@ async def _resume_job(
return return
async with _jobs_lock: async with _jobs_lock:
session_id = _jobs[job_id].get("session_id") or "" session_id = _jobs[job_id].get("session_id") or ""
task = _jobs[job_id].get("task", "") task = _jobs[job_id].get("task", "")
off_record = _jobs[job_id].get("_off_record", False)
from session_store import load as load_session from session_store import load as load_session
history = load_session(session_id) if session_id else [] history = load_session(session_id) if session_id else []
await _finalize_job(job_id, result, session_id, task, history) await _finalize_job(job_id, result, session_id, task, history, off_record=off_record)
except Exception as e: except Exception as e:
logger.exception("Orchestrator resume failed: %s", job_id) logger.exception("Orchestrator resume failed: %s", job_id)
@@ -340,6 +348,7 @@ async def _finalize_job(
session_id: str, session_id: str,
task: str, task: str,
history: list, history: list,
off_record: bool = False,
) -> None: ) -> None:
"""Save session, log the turn, and mark the job complete.""" """Save session, log the turn, and mark the job complete."""
from session_store import save as save_session, generate_session_id from session_store import save as save_session, generate_session_id
@@ -348,10 +357,19 @@ async def _finalize_job(
if not session_id: if not session_id:
session_id = generate_session_id() session_id = generate_session_id()
history.append({"role": "user", "content": task}) host = platform.node()
history.append({"role": "assistant", "content": result.response}) history.append({"role": "user", "content": task, "off_record": off_record})
history.append({
"role": "assistant",
"content": result.response,
"backend": result.backend,
"backend_label": result.backend_label,
"host": host,
"off_record": off_record,
})
save_session(session_id, history) save_session(session_id, history)
log_turn(session_id, task, result.response) if not off_record:
log_turn(session_id, task, result.response)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
async with _jobs_lock: async with _jobs_lock:
@@ -362,6 +380,8 @@ async def _finalize_job(
"response": result.response, "response": result.response,
"tool_calls": result.tool_calls, "tool_calls": result.tool_calls,
"backend": result.backend, "backend": result.backend,
"backend_label": result.backend_label,
"host": host,
"gemini_summary": result.gemini_summary, "gemini_summary": result.gemini_summary,
}) })
logger.info("Orchestrator job complete: %s (%d tool calls)", job_id, len(result.tool_calls)) logger.info("Orchestrator job complete: %s (%d tool calls)", job_id, len(result.tool_calls))

View File

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

View File

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

View File

@@ -614,18 +614,34 @@
.copy-btn:hover { color: var(--text); border-color: var(--muted); } .copy-btn:hover { color: var(--text); border-color: var(--muted); }
.copy-btn.copied { color: var(--success); border-color: var(--success-dim); } .copy-btn.copied { color: var(--success); border-color: var(--success-dim); }
/* Model tag — shown at the bottom of every assistant message */ /* Message metadata — shown in the hover bar below the bubble */
.model-tag { .msg-meta {
display: block; display: flex;
font-size: 0.67rem; align-items: center;
color: #475569; gap: 5px;
margin-top: 0.55rem; flex: 1;
padding-top: 0.4rem; min-width: 0;
border-top: 1px solid #2d3148; font-size: 0.62rem;
text-align: right; color: var(--dim);
letter-spacing: 0.02em; 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 button — shown in error message bubbles */
.retry-btn { .retry-btn {

View File

@@ -116,6 +116,13 @@ Inara reaches out on her own initiative via NC Talk, Google Chat, email, or brow
- [x] `POST /api/push/test` + `POST /api/push/reminders/check` — on-demand test endpoints - [x] `POST /api/push/test` + `POST /api/push/reminders/check` — on-demand test endpoints
- [x] `push_utils.py` — fixed `pywebpush` 2.x key deserialisation (use `Vapid.from_pem()` instead of passing PEM string) - [x] `push_utils.py` — fixed `pywebpush` 2.x key deserialisation (use `Vapid.from_pem()` instead of passing PEM string)
### [UX] Session delete confirmation
The session delete button in the sidebar needs a confirmation step before firing — currently
it deletes immediately on click with no undo. A simple `confirm()` dialog or an inline
"Are you sure? [Delete] [Cancel]" reveal would prevent accidental data loss.
- [ ] Add confirm step to session delete button click handler in `app.js`
- [ ] Consider: also confirm for message-level delete (Edit/Delete hover controls)
### [UI] File attachments in chat ### [UI] File attachments in chat
Upload an image or document inline and have it flow into context. Natural workflow Upload an image or document inline and have it flow into context. Natural workflow
("here's this PDF, summarize it"); local backend already supports multimodal via Open WebUI. ("here's this PDF, summarize it"); local backend already supports multimodal via Open WebUI.