From 6cdb0d1eab863987a74fad55b5c8d3382f04f969 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 00:56:04 -0400 Subject: [PATCH] One profile picker for both surfaces, and open where you left off --- Plugin/unraid/css/varaverk.css | 40 ++++++++++- Plugin/unraid/include/ai.php | 4 ++ Plugin/unraid/include/ai_chat.php | 114 +++++++++++++++++++++++------- Plugin/unraid/pages/scheduler.php | 59 +++++++++++++++- 4 files changed, 185 insertions(+), 32 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 1f1f08e..50d0bb6 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -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; margin-top: 6px; flex-shrink: 0; } #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; - border: 1px solid #24343d; border-radius: 3px; padding: 2px 7px; - white-space: nowrap; max-width: 40%; overflow: hidden; text-overflow: ellipsis; } +#vv-ai-dock-chip { white-space: nowrap; max-width: 240px; 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; } @keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; } 50% { background:#2a2312; color:#d8b25a; } } @@ -1066,3 +1066,37 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .vv-script-args::placeholder { color: #3a3a3a; } .vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; } .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; } diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 236915d..b4a795f 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -1251,6 +1251,10 @@ function vv_ai_chats_list(): array { $out[] = [ 'id' => $d['id'], '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'), 'scope' => (string)($d['scope'] ?? ''), 'title' => (string)($d['title'] ?? 'Untitled conversation'), diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 23bfe81..48dc1e0 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -67,9 +67,6 @@ // on the dashboard. 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. // // 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 { + const chipL = $('chip-l'); + 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)); 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; - 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, @@ -642,11 +652,29 @@ vv_ai_profiles_script(); $('input').focus(); } - const bar = $('profiles'); - if (bar) bar.addEventListener('click', e => { - const b = e.target.closest('.vv-ai-prof'); - if (b) setProfile(b.dataset.prof); - }); + // Picker. Closing on any outside click is registered once per instance and removed by + // teardown — a listener on document that outlives its menu is how a torn-down tab keeps + // reacting to clicks on the one that replaced it. + 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 ─────────────────────────────────────────────────────────── $('send').addEventListener('click', send); @@ -704,6 +732,25 @@ vv_ai_profiles_script(); 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 = { prefix: P, setProfile, newChat, loadChat, send, @@ -712,6 +759,7 @@ vv_ai_profiles_script(); clearInterval(pendingTimer); window.removeEventListener('error', onErr); window.removeEventListener('unhandledrejection', onRej); + document.removeEventListener('click', onDocClick); if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P]; }, }; @@ -823,11 +871,23 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void { ?>
+
- $def): ?> - - +
+ +
+ $def): ?> + + +
+
diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 3bc89f7..f4ed163 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -1073,7 +1073,25 @@ Still the same two servers, two households, the same media stack running itself.
- Assistant · Scheduler + +
+ +
+ $def): ?> + + +
+
@@ -2358,6 +2376,7 @@ function vvClickCog(el) { let vvAiProfile = 'varaverk'; 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 vvAiChatId = ""; // the stored conversation this thread is appending to let vvAiBusy = false; @@ -2365,6 +2384,39 @@ let vvAiPoll = null; 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 // 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. @@ -2379,6 +2431,7 @@ function vvAiDockScope(profile, label, target) { const had = vvAiHist.length > 0; vvAiProfile = profile; 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 // 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. @@ -2400,7 +2453,9 @@ function vvAiDockScope(profile, label, target) { // The scope moved under text already typed. Not blocked — just never silent. const input = document.getElementById('vv-ai-dock-input'); 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'); void chip.offsetWidth; chip.classList.add('vv-ai-chip-flash');