One profile picker for both surfaces, and open where you left off

This commit is contained in:
Gmer4Lfe
2026-08-09 00:56:04 -04:00
parent d9225d06d9
commit 6cdb0d1eab
4 changed files with 185 additions and 32 deletions
+37 -3
View File
@@ -529,9 +529,9 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
#vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e; #vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e;
margin-top: 6px; flex-shrink: 0; } margin-top: 6px; flex-shrink: 0; }
#vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; } #vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; }
#vv-ai-dock-chip { flex-shrink: 0; font-size: 10px; color: #5a7a8a; background: #12191d; #vv-ai-dock-chip { white-space: nowrap; max-width: 240px; overflow: hidden;
border: 1px solid #24343d; border-radius: 3px; padding: 2px 7px; text-overflow: ellipsis; }
white-space: nowrap; max-width: 40%; overflow: hidden; text-overflow: ellipsis; } /* The button around it is .vv-ai-chip and carries the colour, border and padding. */
.vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; } .vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; }
@keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; } @keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; }
50% { background:#2a2312; color:#d8b25a; } } 50% { background:#2a2312; color:#d8b25a; } }
@@ -1066,3 +1066,37 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
.vv-script-args::placeholder { color: #3a3a3a; } .vv-script-args::placeholder { color: #3a3a3a; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; } .vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; } .vv-rsync-save-btn:hover { background: #22382a; }
/* ── AI profile picker ────────────────────────────────────────────────────────
Global rather than emitted with the chat widget, because both surfaces that show it are
styled differently in every other respect: the Monitor card renders a full transcript from
include/ai_chat.php, the Scheduler dock renders its own one-line bar and loads none of that
stylesheet. The picker is the one control they share, so it lives where .vv-ai-dock-* already
does instead of being emitted twice. */
/* ── Profile picker ─────────────────────────────────────────────────────── */
/* The chip borrows the Scheduler dock's language deliberately — same muted blue, same weight,
same 10px — so the two surfaces read as one control that happens to appear in two places. */
.vv-ai-profiles { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-picker { position:relative; flex-shrink:0; }
.vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a;
background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px;
cursor:pointer; font-family:inherit; white-space:nowrap; }
.vv-ai-chip:hover { color:#8fb0c4; border-color:#2d4a6a; }
.vv-ai-chip-c { color:#3a5a6a; font-size:8px; }
.vv-ai-picker.open .vv-ai-chip { color:#8fb0c4; border-color:#2d4a6a; }
.vv-ai-picker.open .vv-ai-chip-c { transform:rotate(180deg); }
.vv-ai-menu { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:60;
min-width:250px; background:#0e0e0e; border:1px solid #2a2a2a; border-radius:5px;
box-shadow:0 6px 18px rgba(0,0,0,.55); overflow:hidden; }
.vv-ai-picker.open .vv-ai-menu { display:block; }
.vv-ai-opt { display:block; width:100%; text-align:left; background:none; border:none;
border-bottom:1px solid #1a1a1a; padding:7px 10px; cursor:pointer;
font-family:inherit; }
.vv-ai-opt:last-child { border-bottom:none; }
.vv-ai-opt:hover { background:#152238; }
.vv-ai-opt.active { background:#12191d; }
.vv-ai-opt.active .vv-ai-opt-l { color:#9bd; }
.vv-ai-opt-l { display:block; font-size:11px; color:#b8b8b8; }
.vv-ai-opt-h { display:block; font-size:10px; color:#4a4a4a; line-height:1.4; margin-top:2px; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; flex:1; min-width:150px; }
+4
View File
@@ -1251,6 +1251,10 @@ function vv_ai_chats_list(): array {
$out[] = [ $out[] = [
'id' => $d['id'], 'id' => $d['id'],
'ts' => (int)($d['ts'] ?? 0), 'ts' => (int)($d['ts'] ?? 0),
// Last activity, as distinct from ts. The list is ordered by creation so the prune
// order and the visible order agree, but "which conversation was I last in" is a
// different question and needs the other timestamp to answer it.
'updated' => (int)($d['updated'] ?? $d['ts'] ?? 0),
'profile' => (string)($d['profile'] ?? 'chat'), 'profile' => (string)($d['profile'] ?? 'chat'),
'scope' => (string)($d['scope'] ?? ''), 'scope' => (string)($d['scope'] ?? ''),
'title' => (string)($d['title'] ?? 'Untitled conversation'), 'title' => (string)($d['title'] ?? 'Untitled conversation'),
+87 -27
View File
@@ -67,9 +67,6 @@
// on the dashboard. // on the dashboard.
require_once __DIR__ . '/ai_profiles.php'; require_once __DIR__ . '/ai_profiles.php';
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
// The conversation store, on its own, with no styling and no chat widget attached. // The conversation store, on its own, with no styling and no chat widget attached.
// //
// Emitted separately because the surface that most needs it is the one that does not want the // Emitted separately because the surface that most needs it is the one that does not want the
@@ -110,6 +107,9 @@ function vv_ai_chat_store_script(): void {
<?php <?php
} }
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
function vv_ai_chat_assets(): void { function vv_ai_chat_assets(): void {
static $done = false; static $done = false;
if ($done) return; if ($done) return;
@@ -132,13 +132,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; } .vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; } .vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* ── Profiles ───────────────────────────────────────────────────────────── */ /* Profile picker styling lives in css/varaverk.css — the Scheduler dock uses the same control
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; } and loads none of this stylesheet. */
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0; .vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; } border-top:1px dashed #1e1e1e; padding-top:8px; }
@@ -213,11 +208,24 @@ function vv_ai_chat_assets(): void {
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; .vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px;
background:#161616; border-color:#333; } background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; } .vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; background:#2a2a2a;
border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-prof:hover { border-color:#888; color:#fff; }
.vv-ai-c .vv-ai-prof.active { background:#152238; border-color:#4a7ab0; color:#9bd; }
.vv-ai-c .vv-ai-prof-hint { display:none; } .vv-ai-c .vv-ai-prof-hint { display:none; }
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather
than a role label. The role labels cost a line each and say the same two things forever — on a
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */
.vv-ai-c .vv-ai-role { display:none; }
.vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body::before { content:' '; color:#3a5a6a; }
.vv-ai-c .vv-ai-msg.user { margin-bottom:5px; }
.vv-ai-c .vv-ai-msg.bot { margin-bottom:11px; }
.vv-ai-c .vv-ai-body code { background:#1a1a1a; border:1px solid #333; border-radius:2px;
padding:0 4px; font-size:11px; color:#9ab; }
.vv-ai-c .vv-ai-body strong { color:#ddd; }
.vv-ai-c .vv-ai-src { font-size:10px; color:#4a4a4a; font-family:monospace; }
.vv-ai-c .vv-ai-src-h { display:none; }
.vv-ai-c .vv-ai-meta { font-size:9px; }
.vv-ai-c .vv-ai-think-t { font-size:9px; padding:1px 6px; }
.vv-ai-c .vv-ai-hint { display:none; } .vv-ai-c .vv-ai-hint { display:none; }
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; } .vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; } .vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
@@ -618,13 +626,15 @@ vv_ai_profiles_script();
// ── Profiles ───────────────────────────────────────────────────────── // ── Profiles ─────────────────────────────────────────────────────────
function applyProfile(p) { function applyProfile(p) {
profile = p; profile = p;
const bar = $('profiles'); const chipL = $('chip-l');
if (bar) bar.querySelectorAll('.vv-ai-prof').forEach(b => if (chipL) chipL.textContent = PROFILES[p] ? PROFILES[p].label : p;
const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p)); b.classList.toggle('active', b.dataset.prof === p));
const hint = $('prof-hint'); const hint = $('prof-hint');
if (hint) hint.textContent = PROFILES[p].hint; if (hint) hint.textContent = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null; const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = PROFILES[p].kind ? '' : 'none'; if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
} }
// Switching moves the floor rather than clearing the transcript. Carrying cited, // Switching moves the floor rather than clearing the transcript. Carrying cited,
@@ -642,11 +652,29 @@ vv_ai_profiles_script();
$('input').focus(); $('input').focus();
} }
const bar = $('profiles'); // Picker. Closing on any outside click is registered once per instance and removed by
if (bar) bar.addEventListener('click', e => { // teardown — a listener on document that outlives its menu is how a torn-down tab keeps
const b = e.target.closest('.vv-ai-prof'); // reacting to clicks on the one that replaced it.
if (b) setProfile(b.dataset.prof); const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
}); const closeMenu = () => { if (picker) picker.classList.remove('open');
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); };
const onDocClick = e => { if (picker && !picker.contains(e.target)) closeMenu(); };
if (picker) {
const chip = $('chip');
chip.addEventListener('click', e => {
e.stopPropagation();
const open = picker.classList.toggle('open');
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
});
$('menu').addEventListener('click', e => {
const b = e.target.closest('.vv-ai-opt');
if (!b) return;
closeMenu();
setProfile(b.dataset.prof);
});
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
}
// ── Wiring ─────────────────────────────────────────────────────────── // ── Wiring ───────────────────────────────────────────────────────────
$('send').addEventListener('click', send); $('send').addEventListener('click', send);
@@ -704,6 +732,25 @@ vv_ai_profiles_script();
applyProfile(profile); applyProfile(profile);
// ── Resume ───────────────────────────────────────────────────────────
// Opens on the conversation last spoken to, rather than an empty box. Chosen by `updated`
// across the whole store, not per surface: the point of one store was that a thread started
// on the dashboard is the one you carry on in the tab, and resuming per-surface would give
// each of them its own idea of where you left off — which is the thing it was meant to stop.
//
// Silent on failure. Nothing here is worth an error message: the fallback is the empty box
// the operator would otherwise have got, and an empty box is a working chat.
if (store && o.resume !== false) {
fetch(API + '?action=chats').then(r => r.json()).then(d => {
if (!d.ok || !(d.chats || []).length) return;
// Do not clobber a conversation the operator has already started. The list arrives after
// construction, so by the time it lands they may have typed and sent something.
if (messages.length || busy) return;
const last = d.chats.slice().sort((a, b) => (b.updated || 0) - (a.updated || 0))[0];
if (last) loadChat(last.id);
}).catch(() => {});
}
const inst = { const inst = {
prefix: P, prefix: P,
setProfile, newChat, loadChat, send, setProfile, newChat, loadChat, send,
@@ -712,6 +759,7 @@ vv_ai_profiles_script();
clearInterval(pendingTimer); clearInterval(pendingTimer);
window.removeEventListener('error', onErr); window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej); window.removeEventListener('unhandledrejection', onRej);
document.removeEventListener('click', onDocClick);
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P]; if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
}, },
}; };
@@ -823,11 +871,23 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
?> ?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;"> <div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- One chip, not a row of buttons. Four profiles as four always-visible buttons spend a whole
line restating three choices you are not making, and the row only grows as profiles are
added. The chip states what is answering; clicking it offers the rest. -->
<div class="vv-ai-profiles" id="<?= $p ?>-profiles"> <div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?> <div class="vv-ai-picker">
<button class="vv-ai-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button" <button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button> aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
<?php endforeach; ?> <div class="vv-ai-menu" id="<?= $p ?>-menu" role="listbox">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
type="button" role="option">
<span class="vv-ai-opt-l"><?= htmlspecialchars($def['label']) ?></span>
<span class="vv-ai-opt-h"><?= htmlspecialchars($def['hint']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span> <span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div> </div>
+57 -2
View File
@@ -1073,7 +1073,25 @@ Still the same two servers, two households, the same media stack running itself.
<div id="vv-ai-dock"> <div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div> <div id="vv-ai-dock-body" style="display:none"></div>
<div id="vv-ai-dock-bar"> <div id="vv-ai-dock-bar">
<span id="vv-ai-dock-chip" title="What the assistant will answer about — follows the view you have open">Assistant · Scheduler</span> <!-- The chip is now the trigger for the profile picker as well as the scope readout.
#vv-ai-dock-chip stays the label span it always was, so every existing write to
its textContent still lands; the button around it carries the decoration. -->
<div class="vv-ai-picker" id="vv-ai-dock-picker">
<button class="vv-ai-chip" id="vv-ai-dock-chipbtn" type="button"
aria-haspopup="listbox" aria-expanded="false"
title="What the assistant will answer about — follows the view you have open. Click to change profile.">
<span id="vv-ai-dock-chip">Assistant · Scheduler</span><span class="vv-ai-chip-c">▾</span>
</button>
<div class="vv-ai-menu" id="vv-ai-dock-menu" role="listbox">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
type="button" role="option">
<span class="vv-ai-opt-l"><?= htmlspecialchars($def['label']) ?></span>
<span class="vv-ai-opt-h"><?= htmlspecialchars($def['hint']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<input type="text" id="vv-ai-dock-input" autocomplete="off" <input type="text" id="vv-ai-dock-input" autocomplete="off"
placeholder="Ask about what is on screen…" placeholder="Ask about what is on screen…"
onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}"> onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}">
@@ -2358,6 +2376,7 @@ function vvClickCog(el) {
let vvAiProfile = 'varaverk'; let vvAiProfile = 'varaverk';
let vvAiScope = 'Scheduler'; let vvAiScope = 'Scheduler';
let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it
let vvAiHist = []; // what the model is told; reset when the scope changes let vvAiHist = []; // what the model is told; reset when the scope changes
let vvAiChatId = ""; // the stored conversation this thread is appending to let vvAiChatId = ""; // the stored conversation this thread is appending to
let vvAiBusy = false; let vvAiBusy = false;
@@ -2365,6 +2384,39 @@ let vvAiPoll = null;
function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); } function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); }
// ── Profile picker ───────────────────────────────────────────────────────────
// The chip states the contract and the thing being asked about; clicking it changes the first
// without moving the second. Same control as the Monitor card's, same stylesheet — the dock keeps
// its own bar because that is a real difference, but which profile answers is not a per-surface
// idea and should not have a per-surface control.
//
// Routes through vvAiDockScope() rather than assigning vvAiProfile, so a profile change gets the
// history reset and the on-screen marker that a scope change already gets. Picking a new contract
// mid-thread and silently feeding it the previous one's turns is the bug that rule exists for.
(function vvAiPickerInit() {
const picker = document.getElementById('vv-ai-dock-picker');
if (!picker) return;
const btn = document.getElementById('vv-ai-dock-chipbtn');
const menu = document.getElementById('vv-ai-dock-menu');
const close = () => { picker.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
btn.addEventListener('click', e => {
e.stopPropagation();
const open = picker.classList.toggle('open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
menu.querySelectorAll('.vv-ai-opt').forEach(o =>
o.classList.toggle('active', o.dataset.prof === vvAiProfile));
});
menu.addEventListener('click', e => {
const o = e.target.closest('.vv-ai-opt');
if (!o) return;
close();
vvAiDockScope(o.dataset.prof, vvAiScopeLabel, vvAiScope);
});
document.addEventListener('click', e => { if (!picker.contains(e.target)) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
})();
// Called by every view switch. Profile and scope are derived from what is open and shown on the // Called by every view switch. Profile and scope are derived from what is open and shown on the
// chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a // chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a
// wrong inference costs a glance instead of a confidently wrong answer. // wrong inference costs a glance instead of a confidently wrong answer.
@@ -2379,6 +2431,7 @@ function vvAiDockScope(profile, label, target) {
const had = vvAiHist.length > 0; const had = vvAiHist.length > 0;
vvAiProfile = profile; vvAiProfile = profile;
vvAiScope = target; vvAiScope = target;
vvAiScopeLabel = label; // kept so the picker can change profile without moving the scope
// Short label from the registry, not a literal map. The map that used to be here was the // Short label from the registry, not a literal map. The map that used to be here was the
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it // fifth place a profile got defined, and it silently fell back to "Assistant" for any id it
// had not been told about — so a new profile would have shown up in the chip as the strict one. // had not been told about — so a new profile would have shown up in the chip as the strict one.
@@ -2400,7 +2453,9 @@ function vvAiDockScope(profile, label, target) {
// The scope moved under text already typed. Not blocked — just never silent. // The scope moved under text already typed. Not blocked — just never silent.
const input = document.getElementById('vv-ai-dock-input'); const input = document.getElementById('vv-ai-dock-input');
if (input.value.trim() !== '') { if (input.value.trim() !== '') {
const chip = document.getElementById('vv-ai-dock-chip'); // The button, not the label span inside it. The flash animates a background, and the
// background moved to the button when the chip became the picker's trigger.
const chip = document.getElementById('vv-ai-dock-chipbtn');
chip.classList.remove('vv-ai-chip-flash'); chip.classList.remove('vv-ai-chip-flash');
void chip.offsetWidth; void chip.offsetWidth;
chip.classList.add('vv-ai-chip-flash'); chip.classList.add('vv-ai-chip-flash');