Put every chat control on one row, the same row everywhere
The picker sat above the transcript and the buttons below it, so the same component read differently on each surface; size is the only thing a placement should get to choose.
This commit is contained in:
@@ -1217,7 +1217,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
|||||||
/* ── Profile picker ─────────────────────────────────────────────────────── */
|
/* ── Profile picker ─────────────────────────────────────────────────────── */
|
||||||
/* The chip borrows the Scheduler dock's language deliberately — same muted blue, same weight,
|
/* 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. */
|
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-picker { position:relative; flex-shrink:0; }
|
||||||
.vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a;
|
.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;
|
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 { color:#8fb0c4; border-color:#2d4a6a; }
|
||||||
.vv-ai-picker.open .vv-ai-chip-c { transform:rotate(180deg); }
|
.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;
|
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-picker.open .vv-ai-menu { display:block; }
|
||||||
.vv-ai-opt { display:block; width:100%; text-align:left; background:none; border:none;
|
.vv-ai-opt { display:block; width:100%; text-align:left; background:none; border:none;
|
||||||
border-bottom:1px solid #1a1a1a; padding:7px 10px; cursor:pointer;
|
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.active .vv-ai-opt-l { color:#9bd; }
|
||||||
.vv-ai-opt-l { display:block; font-size:11px; color:#b8b8b8; }
|
.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-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; }
|
||||||
|
|||||||
@@ -181,10 +181,16 @@ function vv_ai_chat_assets(): void {
|
|||||||
/* ── Composer ───────────────────────────────────────────────────────────── */
|
/* ── Composer ───────────────────────────────────────────────────────────── */
|
||||||
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
||||||
border-radius:6px; padding:10px; background:#0e0e0e; }
|
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-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;
|
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||||||
padding:4px 7px; border-radius:3px; }
|
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 ──────────────── */
|
/* ── 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
|
/* 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;
|
.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-hint { display:none; }
|
|
||||||
|
|
||||||
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather
|
/* 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
|
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-src-h { display:none; }
|
||||||
.vv-ai-c .vv-ai-meta { font-size:9px; }
|
.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-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 { 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; }
|
||||||
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
|
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
|
||||||
@@ -631,8 +635,10 @@ vv_ai_profiles_script();
|
|||||||
const menu = $('menu');
|
const menu = $('menu');
|
||||||
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
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');
|
// The profile's description is the chip's title now. It used to be a sentence sitting beside
|
||||||
if (hint) hint.textContent = PROFILES[p] ? PROFILES[p].hint : '';
|
// 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;
|
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||||||
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
|
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
|
||||||
}
|
}
|
||||||
@@ -652,28 +658,90 @@ vv_ai_profiles_script();
|
|||||||
$('input').focus();
|
$('input').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picker. Closing on any outside click is registered once per instance and removed by
|
// Two pickers now — profile on the left of the control row, saved conversations on the right.
|
||||||
// teardown — a listener on document that outlives its menu is how a torn-down tab keeps
|
// Both close on an outside click or Escape through one pair of document listeners rather than
|
||||||
// reacting to clicks on the one that replaced it.
|
// a pair each, and both are removed by teardown. A listener on document that outlives its menu
|
||||||
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
|
// is how a torn-down tab keeps reacting to clicks on the one that replaced it, and the count
|
||||||
const closeMenu = () => { if (picker) picker.classList.remove('open');
|
// of them to get right should not grow with the number of menus.
|
||||||
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); };
|
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
|
||||||
const onDocClick = e => { if (picker && !picker.contains(e.target)) closeMenu(); };
|
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) {
|
if (picker) {
|
||||||
const chip = $('chip');
|
const chip = $('chip');
|
||||||
chip.addEventListener('click', e => {
|
chip.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
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');
|
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
$('menu').addEventListener('click', e => {
|
$('menu').addEventListener('click', e => {
|
||||||
const b = e.target.closest('.vv-ai-opt');
|
const b = e.target.closest('.vv-ai-opt');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
closeMenu();
|
closeMenus();
|
||||||
setProfile(b.dataset.prof);
|
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 = '<div class="vv-ai-hist-msg">loading…</div>';
|
||||||
|
fetch(API + '?action=chats').then(r => r.json()).then(d => {
|
||||||
|
const rows = (d.ok && d.chats) ? d.chats : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
histMenu.innerHTML = '<div class="vv-ai-hist-msg">no saved conversations yet</div>';
|
||||||
|
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 `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
|
||||||
|
+ ` role="option" data-chat="${esc(c.id)}">`
|
||||||
|
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
|
||||||
|
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
|
||||||
|
+ `</button>`;
|
||||||
|
}).join('');
|
||||||
|
}).catch(() => {
|
||||||
|
histMenu.innerHTML = '<div class="vv-ai-hist-msg">could not read the store</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
histMenu.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('[data-chat]');
|
||||||
|
if (!b) return;
|
||||||
|
closeMenus();
|
||||||
|
loadChat(b.dataset.chat);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Wiring ───────────────────────────────────────────────────────────
|
// ── Wiring ───────────────────────────────────────────────────────────
|
||||||
@@ -759,6 +827,9 @@ vv_ai_profiles_script();
|
|||||||
window.removeEventListener('error', onErr);
|
window.removeEventListener('error', onErr);
|
||||||
window.removeEventListener('unhandledrejection', onRej);
|
window.removeEventListener('unhandledrejection', onRej);
|
||||||
document.removeEventListener('click', onDocClick);
|
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];
|
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.
|
// 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
|
// Every placement renders the same thing. What a page chooses is how much room it gets — width is
|
||||||
// height transcript height; a fixed value in compact mode, since a card in a grid row
|
// the container's business, the two heights are this function's — and which extra controls belong
|
||||||
// cannot grow with its content without dragging the row's other cards with it.
|
// to that surface. Everything else is fixed on purpose: the control row, its order, what the
|
||||||
// Setting it also adds the expand control — a fixed height is exactly the situation
|
// buttons do. A chat that rearranges itself per tab is three components wearing one name.
|
||||||
// where you sometimes want more room, and there is nothing to expand without one.
|
//
|
||||||
|
// 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
|
// 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
|
// answer stops needing a scroll for most questions without the card swallowing the
|
||||||
// page it sits on
|
// page it sits on
|
||||||
// empty empty-state text
|
// 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 {
|
function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
||||||
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
||||||
$compact = !empty($o['compact']);
|
$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.';
|
$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
|
// 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.
|
// rather than the include guessing at one it cannot see.
|
||||||
@@ -870,42 +950,54 @@ 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-picker">
|
|
||||||
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
|
|
||||||
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
||||||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vv-ai-composer">
|
<div class="vv-ai-composer">
|
||||||
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
|
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
|
||||||
|
title="Ctrl+Enter to send"
|
||||||
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
|
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
|
||||||
|
|
||||||
|
<!-- One row, and everything on it is clickable. The profile chip states what is answering and
|
||||||
|
holds the left end; every action holds the right. Nothing else lives here — the keyboard
|
||||||
|
hint moved to the textarea's title and the profile's description to the chip's, because a
|
||||||
|
row that mixes prose with controls makes you read it to find the button.
|
||||||
|
|
||||||
|
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 only grow as profiles are added. -->
|
||||||
<div class="vv-ai-ctrls">
|
<div class="vv-ai-ctrls">
|
||||||
<?= $o['controls'] ?? '' ?>
|
<div id="<?= $p ?>-profiles">
|
||||||
<?php if ($height !== ''): ?>
|
<div class="vv-ai-picker">
|
||||||
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
|
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
|
||||||
title="Give the conversation more room">⤢</button>
|
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
|
||||||
<?php endif; ?>
|
<div class="vv-ai-menu" id="<?= $p ?>-menu" role="listbox">
|
||||||
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
|
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
|
||||||
<span class="vv-ai-hint">Ctrl+Enter to send</span>
|
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
|
||||||
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vv-ai-actions">
|
||||||
|
<?= $o['controls'] ?? '' ?>
|
||||||
|
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
|
||||||
|
title="Give the conversation more room">⤢</button>
|
||||||
|
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
|
||||||
|
from either is the same thread. -->
|
||||||
|
<div class="vv-ai-picker" id="<?= $p ?>-hist">
|
||||||
|
<button class="vv-ai-btn ghost vv-ai-histbtn" id="<?= $p ?>-hist-b" type="button"
|
||||||
|
aria-haspopup="listbox" aria-expanded="false"
|
||||||
|
title="Saved conversations">▾</button>
|
||||||
|
<div class="vv-ai-menu vv-ai-menu-r" id="<?= $p ?>-hist-menu" role="listbox"></div>
|
||||||
|
</div>
|
||||||
|
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
|
||||||
|
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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-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-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-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 ───────────────────────────────────────────────────── */
|
/* ── Token accounting ───────────────────────────────────────────────────── */
|
||||||
/* Splits on the second banner stat rather than the third, so the host window stays narrow and
|
/* 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')) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
|
||||||
|
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
|
||||||
vv_ai_chat_markup('vv-ai', [
|
vv_ai_chat_markup('vv-ai', [
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
|
'height' => '52vh',
|
||||||
|
'tall' => '85vh',
|
||||||
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
|
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
|
||||||
. 'documentation, with sources.',
|
. 'documentation, with sources.',
|
||||||
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
|
'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
|
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>',
|
||||||
// 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' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>'
|
|
||||||
. '<span class="vv-ai-hint">build ' . $_vv_ai_build
|
|
||||||
. ' <span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>',
|
|
||||||
]);
|
]);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<!-- The build stamp and the liveness marker stay on this tab, below the composer rather than in
|
||||||
|
it. 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 — this
|
||||||
|
marker answers it from the browser, and it is where every deploy on this tab gets checked.
|
||||||
|
It sits here because the control row above is for controls: it was the one piece of prose in
|
||||||
|
a line of buttons, and it is read deliberately rather than glanced at. -->
|
||||||
|
<div class="vv-ai-buildline">build <?= $_vv_ai_build ?><span id="vv-ai-live" style="color:#e57"> · JS NOT RUNNING</span></div>
|
||||||
|
|
||||||
<!-- Settings. Below the composer and closed by default: these change how the next answer is
|
<!-- Settings. Below the composer and closed by default: these change how the next answer is
|
||||||
built, not what it is asked, so they should be reachable without being in the way. The
|
built, not what it is asked, so they should be reachable without being in the way. The
|
||||||
header carries the current values so the state is legible while collapsed. -->
|
header carries the current values so the state is legible while collapsed. -->
|
||||||
|
|||||||
Reference in New Issue
Block a user