diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index e1b713d..66e16ac 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -1217,7 +1217,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } /* ── 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; @@ -1227,9 +1226,16 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .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; +/* Opens upward. Every picker in the plugin now sits on the composer's bottom row, so a menu + dropping downward would fall out of the component — off the bottom of the Monitor card and + over the help panel beneath it. Anchored to the trigger's top edge instead. */ +.vv-ai-menu { display:none; position:absolute; bottom: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; } + box-shadow:0 -6px 18px rgba(0,0,0,.55); overflow:hidden; } +/* Right-anchored variant for triggers near the right edge, where a left-anchored menu would run + off the card. Used by the saved-conversations picker, whose list is unbounded — capped so a + long history scrolls in place rather than running off the top of the viewport. */ +.vv-ai-menu-r { left:auto; right:0; max-height:min(60vh,420px); overflow-y:auto; } .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; @@ -1240,4 +1246,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .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; } +/* Loading, empty and error in the saved-conversations menu: one line in the menu's own voice, + styled so it does not read as a row you might try to click. */ +.vv-ai-hist-msg { font-size:10px; color:#4a4a4a; font-style:italic; padding:9px 10px; } diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 9c846ab..c81e632 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -181,10 +181,16 @@ function vv_ai_chat_assets(): void { /* ── Composer ───────────────────────────────────────────────────────────── */ .vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626; border-radius:6px; padding:10px; background:#0e0e0e; } +/* Picker left, actions right, nothing in between. margin-left:auto on the group rather than on + whichever control happens to be first means the right end stays anchored however many extra + controls a page passes in. Wrapping is allowed because the Monitor card is narrow, and when it + wraps the two groups part cleanly instead of interleaving. */ .vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } +.vv-ai-actions { display:flex; gap:8px; align-items:center; margin-left:auto; } .vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px; padding:4px 7px; border-radius:3px; } -.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; } +/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */ +.vv-ai-grow, .vv-ai-histbtn { min-width:30px; text-align:center; } /* ── Compact form, for a chat living inside a Monitor card ──────────────── */ /* Not a different component — the same markup with the chrome pulled in. The card supplies the @@ -208,7 +214,6 @@ function vv_ai_chat_assets(): void { .vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; background:#161616; border-color:#333; } .vv-ai-c .vv-ai-input:focus { border-color:#6495ed; } -.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 @@ -226,7 +231,6 @@ function vv_ai_chat_assets(): void { .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-btn { padding:4px 11px; font-size:11px; } .vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; } .vv-ai-c .vv-ai-msg { margin-bottom:12px; } @@ -631,8 +635,10 @@ vv_ai_profiles_script(); 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] ? PROFILES[p].hint : ''; + // The profile's description is the chip's title now. It used to be a sentence sitting beside + // the chip, which cost a line on every surface and was already hidden on the compact ones. + const chip = $('chip'); + if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : ''; const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null; if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none'; } @@ -652,28 +658,90 @@ vv_ai_profiles_script(); $('input').focus(); } - // 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(); }; + // Two pickers now — profile on the left of the control row, saved conversations on the right. + // Both close on an outside click or Escape through one pair of document listeners rather than + // a pair each, and both are 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, and the count + // of them to get right should not grow with the number of menus. + const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null; + const histWrap = $('hist'); + + const closeMenus = () => { + if (picker) { picker.classList.remove('open'); + const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); } + if (histWrap) { histWrap.classList.remove('open'); + const h = $('hist-b'); if (h) h.setAttribute('aria-expanded', 'false'); } + }; + const onDocClick = e => { + if (picker && picker.contains(e.target)) return; + if (histWrap && histWrap.contains(e.target)) return; + closeMenus(); + }; + const onDocKey = e => { if (e.key === 'Escape') closeMenus(); }; + document.addEventListener('click', onDocClick); + document.addEventListener('keydown', onDocKey); + if (picker) { const chip = $('chip'); chip.addEventListener('click', e => { e.stopPropagation(); - const open = picker.classList.toggle('open'); + const open = !picker.classList.contains('open'); + closeMenus(); + picker.classList.toggle('open', open); chip.setAttribute('aria-expanded', open ? 'true' : 'false'); }); $('menu').addEventListener('click', e => { const b = e.target.closest('.vv-ai-opt'); if (!b) return; - closeMenu(); + closeMenus(); setProfile(b.dataset.prof); }); - document.addEventListener('click', onDocClick); - document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); }); + } + + // Saved conversations, off the same store the Conversations card reads — so every placement + // can reach its history whether or not it has a card beside it. Read when opened rather than + // kept in step: a list refreshed on a timer for a menu nobody has opened is work spent on + // nothing, and the moment it is looked at is the moment it must be right. + if (histWrap) { + const histBtn = $('hist-b'), histMenu = $('hist-menu'); + histBtn.addEventListener('click', e => { + e.stopPropagation(); + const open = !histWrap.classList.contains('open'); + closeMenus(); + histWrap.classList.toggle('open', open); + histBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (!open) return; + + histMenu.innerHTML = '
loading…
'; + fetch(API + '?action=chats').then(r => r.json()).then(d => { + const rows = (d.ok && d.chats) ? d.chats : []; + if (!rows.length) { + histMenu.innerHTML = '
no saved conversations yet
'; + return; + } + // Same tagging rule as the Conversations card: a scope always says something, a profile + // says something unless it is the ordinary one. + const PR = window.VvAiProfiles || {}; + histMenu.innerHTML = rows.map(c => { + const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : ''; + const tag = [prof, c.scope || ''].filter(Boolean).join(' · '); + return ``; + }).join(''); + }).catch(() => { + histMenu.innerHTML = '
could not read the store
'; + }); + }); + + histMenu.addEventListener('click', e => { + const b = e.target.closest('[data-chat]'); + if (!b) return; + closeMenus(); + loadChat(b.dataset.chat); + }); } // ── Wiring ─────────────────────────────────────────────────────────── @@ -759,6 +827,9 @@ vv_ai_profiles_script(); window.removeEventListener('error', onErr); window.removeEventListener('unhandledrejection', onRej); document.removeEventListener('click', onDocClick); + // The keydown pair was previously registered and never removed, so every tab swap left + // another Escape handler bound to a dead menu. + document.removeEventListener('keydown', onDocKey); if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P]; }, }; @@ -836,21 +907,30 @@ vv_ai_profiles_script(); } // One instance's markup. The prefix composes every id, so a page may render more than one. -// profile which profile button starts active -// compact card-sized chrome, for a chat living inside a Monitor card -// height transcript height; a fixed value in compact mode, since a card in a grid row -// cannot grow with its content without dragging the row's other cards with it. -// Setting it also adds the expand control — a fixed height is exactly the situation -// where you sometimes want more room, and there is nothing to expand without one. +// +// Every placement renders the same thing. What a page chooses is how much room it gets — width is +// the container's business, the two heights are this function's — and which extra controls belong +// to that surface. Everything else is fixed on purpose: the control row, its order, what the +// buttons do. A chat that rearranges itself per tab is three components wearing one name. +// +// profile which profile starts active +// compact card-sized chrome, for a chat living inside a card +// height resting transcript height. Defaults per mode rather than being left empty: the +// expand control is standard, and a control with no second height to move to is a +// button that does nothing on the one surface that forgot to pass a number. // tall the expanded height. Defaults to 2.5x height, which is the point where a long // answer stops needing a scroll for most questions without the card swallowing the // page it sits on // empty empty-state text -// controls extra markup dropped into the composer's control strip +// scope what this conversation is about — string, or a function re-read at send time for a +// surface whose subject follows the view the operator has open +// controls extra markup dropped in at the head of the action group function vv_ai_chat_markup(string $prefix, array $o = []): void { $p = htmlspecialchars($prefix, ENT_QUOTES); $compact = !empty($o['compact']); - $height = $o['height'] ?? ''; + // A card-sized chat and a full-page one want very different resting heights, and neither wants + // none: see `height` above. + $height = $o['height'] ?? ($compact ? '300px' : '46vh'); $empty = $o['empty'] ?? 'Ask Varaverk about itself.'; // 2.5x by default, computed here so the page can override with a value that suits its layout // rather than the include guessing at one it cannot see. @@ -870,42 +950,54 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void { ?>
- -
-
- -
- $def): ?> - - -
-
- -
-
>
+ +
- - - - - - Ctrl+Enter to send - +
+
+ +
+ $def): ?> + + +
+
+
+ +
+ + + +
+ +
+
+ + +
diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index cd73867..29a9c12 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -119,6 +119,7 @@ if (is_dir('/var/log/varaverk')) { .vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; } .vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; } +.vv-ai-buildline { font-size:10px; color:#3a3a3a; font-family:monospace; margin:6px 2px 0; } /* ── Token accounting ───────────────────────────────────────────────────── */ /* Splits on the second banner stat rather than the third, so the host window stays narrow and @@ -227,21 +228,27 @@ if (is_dir('/var/log/varaverk')) { 'varaverk', + 'height' => '52vh', + 'tall' => '85vh', 'empty' => "Ask Varaverk about itself. Answers come only from this installation's own " . 'documentation, with sources.', 'placeholder' => 'e.g. what stops rsync and the mover running at once?', - // The build stamp and the liveness marker stay on this tab. Unraid swaps tab content by - // AJAX without tearing down the previous page's JavaScript, so "is the browser running the - // code I just deployed" is not answerable from the server — the marker answers it from the - // browser, and it is where every deploy on this tab gets checked. - 'controls' => '' - . 'build ' . $_vv_ai_build - . ' · JS NOT RUNNING', + 'controls' => '', ]); ?> + +
build · JS NOT RUNNING
+