To the operator there is one box on screen, so expanding while reading the assistant and finding the mesh collapsed reads as the card forgetting.
2604 lines
140 KiB
PHP
2604 lines
140 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The conversation surface itself — transcript, composer, profile and history pickers, source
|
||
// viewer and the stored-chat list — rendered wherever a chat belongs. All three surfaces use
|
||
// it: the AI tab, the Monitor tab's AI row, and the Scheduler's right-hand panel.
|
||
//
|
||
// WHY THIS IS AN INCLUDE
|
||
// There were two places that needed a real transcript and one implementation, which meant the
|
||
// second one was going to be a copy. Everything subtle here — the busy flag that must never
|
||
// stick, URLSearchParams instead of FormData, the poll ceiling, escaping before markdown, the
|
||
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them
|
||
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
|
||
//
|
||
// The Scheduler panel used to be exactly that copy: its own bar, send loop, poll and store
|
||
// handling. It proved the point — a thread there died on reload while the other two were saved,
|
||
// and its bar drifted into a different shape from the same control everywhere else. It is an
|
||
// instance now. What was genuinely particular to it turned into options rather than a second
|
||
// implementation: `scope` as a function for a subject that follows the open view, `beforeSend`
|
||
// for the one reply that is recorded rather than asked, `think` for reasoning on diagnosis only,
|
||
// `setHeights()` for a placement whose size is a share of a panel and not knowable until layout.
|
||
//
|
||
// WHAT A PLACEMENT MAY CHOOSE
|
||
// How much room it gets, and which extra controls belong to that surface. Not the shape: the
|
||
// control row, its order, and what the buttons do are fixed. A chat that rearranges itself per
|
||
// tab is three components wearing one name, which is the state this replaced.
|
||
//
|
||
// INSTANCES
|
||
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance
|
||
// registers under window.__vvAiChat[prefix] and tears down the previous holder of that prefix
|
||
// on construction — Unraid swaps tab content by AJAX without unloading the old page's
|
||
// JavaScript, so the previous copy's timers are still running when the new one arrives.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// The transcript and what the model is sent are not the same list.
|
||
// messages[] is everything on screen and everything stored. sendFrom is the index the
|
||
// model is allowed to see from. Switching profile moves sendFrom to the end rather than
|
||
// emptying the transcript: carrying cited, retrieval-grounded turns into a mode with no
|
||
// retrieval makes the model refer to sources it can no longer see, but erasing what the
|
||
// operator just read to achieve that is worse.
|
||
//
|
||
// Citations and sources are wired by delegation, not by onclick.
|
||
// Both carry a file path that came from the index. Interpolating one into an onclick
|
||
// attribute means a path containing a quote executes; data attributes plus one listener on
|
||
// the transcript removes the whole class rather than escaping around it.
|
||
//
|
||
// Storage is automatic and capped.
|
||
// A conversation saves itself when a turn completes and the oldest drops off past
|
||
// AI_CHAT_HISTORY_MAX. There is no Save button, because a chat worth keeping is not
|
||
// reliably one you knew was worth keeping while you were having it.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Every rendered string is escaped, and the minimal markdown pass runs strictly afterwards, so
|
||
// no model or file derived input can introduce markup.
|
||
//
|
||
// Every failure path ends in a visible message in the transcript. A hang is the one failure
|
||
// that reports nothing, and this endpoint has produced one: a multipart POST that left the
|
||
// browser and never reached PHP, with no status, no fatal and no entry in any log.
|
||
//
|
||
// Read-only with respect to the system. Nothing here runs a script or edits conf.
|
||
//
|
||
// EXPORTS
|
||
// vv_ai_chat_assets() styles + the VvAiChat / VvAiChatList factories, once per page
|
||
// vv_ai_chat_markup($prefix,$o) profile bar, transcript, composer for one instance
|
||
// vv_ai_chat_list_markup($prefix) the stored-conversations list container
|
||
//
|
||
// DEPENDS ON
|
||
// include/ai_profiles.php the profile registry, served to the browser rather than restated
|
||
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
|
||
// api/readscript.php source viewer contents
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Required directly, not assumed. The Monitor tab pulls this file in without include/ai.php,
|
||
// so relying on something else having loaded the registry first works on the AI tab and fatals
|
||
// on the dashboard.
|
||
require_once __DIR__ . '/ai_profiles.php';
|
||
// The mesh pane the chat card can switch to. Required here rather than by each page, so a page
|
||
// that mounts a chat gets the option without having to know the store exists.
|
||
require_once __DIR__ . '/node_chat.php';
|
||
|
||
// The conversation store, on its own, with no styling and no chat widget attached.
|
||
//
|
||
// Emitted separately because a page may want the store without the component — a list of saved
|
||
// conversations, or a save, with no transcript rendered. Every surface currently takes both, but
|
||
// the two are not the same dependency and pulling in a stylesheet to reach a save function is how
|
||
// they would become one.
|
||
//
|
||
// Where a conversation lives is not a presentation decision, so it gets one answer for every
|
||
// surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
|
||
// everywhere at once because there is only one writer.
|
||
function vv_ai_chat_store_script(): void {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$done = true;
|
||
?>
|
||
<script>
|
||
(function () {
|
||
const API = '/plugins/varaverk/api/ai.php';
|
||
// Returns the id so a caller can keep appending to one conversation instead of minting a row
|
||
// per turn. Fire and forget on failure: a store that cannot be written is not a reason to lose
|
||
// the answer already on screen.
|
||
window.VvAiChatSave = function (msgs, profile, scope, id) {
|
||
if (!msgs || !msgs.length) return Promise.resolve(null);
|
||
return fetch(API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams({
|
||
action: 'chat_save', id: id || '', profile: profile || 'chat',
|
||
scope: scope || '', messages: JSON.stringify(msgs),
|
||
}),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => (d && d.ok) ? d.id : null)
|
||
.catch(() => null);
|
||
};
|
||
})();
|
||
</script>
|
||
<?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 {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$done = true;
|
||
vv_ai_chat_store_script();
|
||
?>
|
||
<style>
|
||
/* ── Shared tone ─────────────────────────────────────────────────────────── */
|
||
.vv-ai-ok { color:#6fcf97 !important; }
|
||
.vv-ai-warn { color:#ffb74d !important; }
|
||
.vv-ai-bad { color:#e57 !important; }
|
||
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
|
||
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
|
||
border-radius:3px; cursor:pointer; font-family:inherit; }
|
||
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
|
||
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
|
||
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
|
||
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
|
||
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
|
||
.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; }
|
||
|
||
/* Profile picker styling lives in css/varaverk.css, alongside the menus it shares its shape
|
||
with, rather than here. */
|
||
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
|
||
border-top:1px dashed #1e1e1e; padding-top:8px; }
|
||
|
||
/* ── Transcript ─────────────────────────────────────────────────────────── */
|
||
/* Transparent so the mark on the wrapper shows through. The dark ground moves to the wrapper —
|
||
painting it here would put an opaque layer between the badge and the text. */
|
||
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:transparent;
|
||
position:relative; z-index:1;
|
||
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||
|
||
/* The Varaverk mark, behind every conversation on every page.
|
||
On the wrapper rather than on the scroller: a background on .vv-ai-chat would be anchored to
|
||
its padding box and drift as the transcript scrolls, and a child element would scroll away with
|
||
the messages. inset:0 on the positioned wrapper holds it still behind whatever moves. */
|
||
.vv-ai-chat-wrap { background:#0b0b0b; border-radius:6px; }
|
||
.vv-ai-chat-wrap::before {
|
||
content:''; position:absolute; inset:0; z-index:0; pointer-events:none;
|
||
background:url('/plugins/varaverk/icons/varaverk.png') center 58%/min(84%,520px) no-repeat;
|
||
opacity:.032; filter:grayscale(1);
|
||
}
|
||
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
|
||
.vv-ai-msg { margin-bottom:16px; }
|
||
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
|
||
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
|
||
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
|
||
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
|
||
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
|
||
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
|
||
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
|
||
overflow-x:auto; margin:8px 0; }
|
||
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
|
||
/* ── Code cards ─────────────────────────────────────────────────────────────
|
||
Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on
|
||
15" panels with no pointer near them, so a control that only exists on hover does not exist. */
|
||
/* Sits directly above the composer, so what it acts on — the exchange just above — reads left to
|
||
right into the box you would retype it in. */
|
||
/* Purely a positioning context. Deliberately NOT a flex item that grows: the parent is a column
|
||
flex container, .vv-ai-chat was flex:0 1 auto there, and it carries its own height — set inline
|
||
by setHeights() for the placements whose size is a share of a panel. A wrapper with flex:1 1
|
||
auto would stretch past the transcript and leave a dead strip under it, which is the same shape
|
||
of bug as the grid-stretch one. Block-level, no padding, no flex: the geometry is unchanged and
|
||
only the coordinate system is new. min-height:0 because flex items default to auto and would
|
||
refuse to shrink. */
|
||
.vv-ai-chat-wrap { position:relative; min-width:0; min-height:0; }
|
||
.vv-ai-jump { position:absolute; left:50%; transform:translateX(-50%); bottom:8px; z-index:3;
|
||
background:#242424; border:1px solid #3a3a3a; color:#ccc; font-size:10px;
|
||
padding:3px 12px; border-radius:11px; cursor:pointer; opacity:.94; }
|
||
.vv-ai-jump:hover { background:#2e2e2e; color:#fff; }
|
||
|
||
/* Filter box at the head of the saved-conversation menu. Borderless except underneath, so it
|
||
reads as part of the menu rather than a control sitting on top of one. */
|
||
.vv-ai-hist-f { width:100%; box-sizing:border-box; background:#0d0d0d; border:none;
|
||
border-bottom:1px solid #262626; color:#ccc; font-size:11px;
|
||
padding:6px 9px; outline:none; }
|
||
.vv-ai-hist-f::placeholder { color:#3f3f3f; }
|
||
|
||
/* Folded answers cap at a readable height with a hard bottom edge rather than a fade — a fade
|
||
over a code block reads as a rendering fault. */
|
||
.vv-ai-fold { max-height:420px; overflow:hidden; }
|
||
.vv-ai-more { display:block; margin:4px 0 2px; }
|
||
|
||
.vv-ai-last { display:flex; gap:10px; padding:0 2px 4px; }
|
||
.vv-ai-lnk { background:none; border:none; padding:0; font-size:10px; color:#4a4a4a;
|
||
cursor:pointer; text-decoration:underline; text-underline-offset:2px; }
|
||
.vv-ai-lnk:hover { color:#8a8a8a; }
|
||
|
||
.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; }
|
||
.vv-ai-code pre { margin:0; border:none; border-radius:0; }
|
||
.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px;
|
||
background:#1b1b1b; border-bottom:1px solid #222; font-size:10px; }
|
||
.vv-ai-code-lang { color:#7a9; text-transform:uppercase; letter-spacing:.4px; }
|
||
.vv-ai-code-n { color:#555; }
|
||
.vv-ai-code-sp { flex:1; }
|
||
.vv-ai-code-btn { background:#232323; border:1px solid #333; color:#bbb; font-size:10px;
|
||
padding:2px 8px; border-radius:3px; cursor:pointer; line-height:1.5; }
|
||
.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; }
|
||
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
|
||
/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */
|
||
/* ── Diff view ──────────────────────────────────────────────────────────────
|
||
Replaces the code body rather than sitting beside it: on a 15" panel the point of a diff is to
|
||
be the thing you are reading, and showing both doubles the height to say the same thing twice. */
|
||
.vv-ai-code.diffing pre { display:none; }
|
||
.vv-ai-diff { font-family:monospace; font-size:11.5px; line-height:1.5; }
|
||
.vv-ai-hunk { border-top:1px solid #222; }
|
||
.vv-ai-hunk-h { display:flex; align-items:center; gap:8px; padding:3px 8px; background:#161616;
|
||
font-size:10px; color:#666; font-family:inherit; }
|
||
.vv-ai-hunk-n { color:#4a4a4a; }
|
||
.vv-ai-dl { display:flex; padding:0 8px; white-space:pre-wrap; word-break:break-word; }
|
||
/* The marker column is what makes a diff readable without colour — which matters for the
|
||
colour-blind case and for a screen being read from across a room. */
|
||
.vv-ai-dm { display:inline-block; width:1.2em; flex:0 0 auto; color:#3f3f3f; user-select:none; }
|
||
.vv-ai-dl.ctx { color:#6a6a6a; }
|
||
.vv-ai-dl.add { background:#12240f; color:#9fd68a; }
|
||
.vv-ai-dl.del { background:#2a1212; color:#d68a8a; }
|
||
.vv-ai-dl.add .vv-ai-dm { color:#5f9f4f; }
|
||
.vv-ai-dl.del .vv-ai-dm { color:#9f5f5f; }
|
||
.vv-ai-diff-warn { padding:6px 9px; font-size:10.5px; color:#d8b070; background:#241d10;
|
||
border-bottom:1px solid #222; font-family:inherit; }
|
||
|
||
.vv-ai-code.numbered code { counter-reset:vvln; }
|
||
.vv-ai-cl { counter-increment:vvln; }
|
||
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
|
||
text-align:right; color:#3f3f3f; user-select:none; }
|
||
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
|
||
.vv-ai-cite:hover { text-decoration:underline; }
|
||
|
||
.vv-ai-danger { background:#1f0d0d; border:1px solid #4a1f1f; border-left:3px solid #e57;
|
||
border-radius:4px; padding:8px 10px; margin-bottom:9px; font-size:11px;
|
||
line-height:1.55; color:#d99; }
|
||
.vv-ai-danger strong { color:#f88; }
|
||
|
||
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
|
||
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
|
||
.vv-ai-think-t:hover { color:#777; border-color:#333; }
|
||
/* Same caret the sources block uses, and for the same reason: the control has to say which way it
|
||
is pointing, or "reasoning (1,284 chars)" reads as a label rather than as something to click. */
|
||
.vv-ai-think-car { display:inline-block; width:10px; }
|
||
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
|
||
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px;
|
||
white-space:pre-wrap; word-break:break-word; }
|
||
.vv-ai-think.open { display:block; }
|
||
/* The live pair is hidden by its wrapper, never by [hidden] on the pair itself: both carry an
|
||
author display rule, and an author rule beats the UA stylesheet's [hidden] — so hiding them
|
||
directly would do nothing at all. The wrapper has no display of its own, so [hidden] holds. */
|
||
|
||
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
|
||
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a;
|
||
margin-bottom:4px; cursor:pointer; user-select:none; }
|
||
.vv-ai-src-h:hover { color:#6a6a6a; }
|
||
.vv-ai-src-car { display:inline-block; width:10px; }
|
||
.vv-ai-src.collapsed .vv-ai-src-l { display:none; }
|
||
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
|
||
.vv-ai-src-i:hover { color:#8a8a8a; }
|
||
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
|
||
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
|
||
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
|
||
|
||
/* An offer's buttons sit under the message that made them, indented to the body so they read as
|
||
part of what was said rather than as composer controls that drifted up the transcript. Once
|
||
answered the pair is replaced by the decision, which is quieter than a disabled button and
|
||
still says what happened. */
|
||
.vv-ai-offer { display:flex; gap:7px; margin-top:8px; }
|
||
.vv-ai-offer-done { font-size:10px; color:#4a4a4a; font-style:italic; margin-top:6px; }
|
||
|
||
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
|
||
/* The streaming body sits in the pending bubble and is replaced wholesale by the finished message,
|
||
so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret
|
||
marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */
|
||
/* Reasoning in flight has no styling of its own. It used to: a capped, self-scrolling window,
|
||
justified as something to glance at rather than read. That made two presentations of one thing —
|
||
the block you were watching turned into a different control the moment the answer arrived — and
|
||
put a nested scrollbar inside a scrolling transcript. It uses the ordinary toggle now, so the
|
||
compact palette and everything else that dresses .vv-ai-think applies to it for free. The cap
|
||
was also defending against reasoning pushing the answer off screen, which is what Auto Scroll
|
||
is for; that control now actually holds, so the cap was solving it twice. */
|
||
|
||
.vv-ai-stream { margin-top:6px; }
|
||
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
|
||
the button's job swapped — the label alone is easy to miss mid-answer. */
|
||
.vv-ai-btn.vv-ai-stop { background:#5a2b2b; border-color:#7a3b3b; color:#f0d8d8; }
|
||
.vv-ai-stream::after { content:'▌'; margin-left:1px; opacity:.55; animation:vvAiPulse 1.1s infinite; }
|
||
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
|
||
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||
|
||
/* ── Composer ───────────────────────────────────────────────────────────── */
|
||
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
||
border-radius:6px; padding:10px; background:#0e0e0e; }
|
||
/* Profile left, conversation actions centred, window control right. The outer groups take equal
|
||
flex and the middle takes only what it needs, which centres it against the composer above
|
||
rather than against whatever the left group currently measures — the Scheduler's chip changes
|
||
width every time the operator opens a different script. Wrapping is allowed because the Monitor
|
||
card is narrow, and when it wraps the three groups part cleanly instead of interleaving. */
|
||
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-side { flex:1 1 0; min-width:0; display:flex; align-items:center; }
|
||
.vv-ai-tail { justify-content:flex-end; }
|
||
.vv-ai-actions { display:flex; gap:8px; align-items:center; flex:0 0 auto; }
|
||
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||
padding:4px 7px; border-radius:3px; }
|
||
/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */
|
||
.vv-ai-grow { min-width:30px; text-align:center; }
|
||
|
||
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
|
||
reads as that card's heading rather than as a second one underneath it. */
|
||
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
|
||
/* flex:1 1 0 on both outer groups, not 1 1 auto — with auto, the basis is the content, so a long
|
||
title on one side and two buttons on the other take different shares and the middle drifts off
|
||
centre. A zero basis makes the two claims equal whatever they contain. */
|
||
.vv-ai-head-c { display:flex; align-items:center; gap:10px; flex:0 0 auto; }
|
||
.vv-ai-head-r { display:flex; align-items:center; gap:6px; flex:1 1 0; min-width:0;
|
||
justify-content:flex-end; }
|
||
/* Checkbox and label move together, and the label is clickable — a 13px box is a poor target on a
|
||
panel being used from across a room. */
|
||
.vv-ai-cb { display:flex; align-items:center; gap:4px; font-size:10px; color:#5a5a5a;
|
||
cursor:pointer; user-select:none; white-space:nowrap; text-transform:none;
|
||
letter-spacing:0; }
|
||
.vv-ai-cb input { margin:0; cursor:pointer; }
|
||
.vv-ai-cb:hover { color:#8a8a8a; }
|
||
/* Narrow placements drop the labels rather than wrapping the banner onto a second line, which
|
||
would change the measured chrome height the Scheduler subtracts. */
|
||
@media (max-width:560px) { .vv-ai-cb span { display:none; } }
|
||
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 0; min-width:0;
|
||
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
|
||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
|
||
/* Shortcuts. The toggle lives in the banner with the other window controls; this is only the list
|
||
it opens. Shut, the block occupies nothing at all — no line, no margin — because the control
|
||
that speaks for it is already on screen above. Open, it is a two-column list that wraps to one
|
||
on a narrow card, and the transcript below gives up exactly its height. */
|
||
.vv-ai-keys { line-height:1; }
|
||
.vv-ai-keysbtn { font-size:10px !important; line-height:1; padding:4px 10px !important;
|
||
letter-spacing:0.04em; flex-shrink:0; }
|
||
/* line-height restored: the wrapper drops it to 1 to tighten the collapsed line, and inheriting
|
||
that here would stack the open rows on top of each other. */
|
||
.vv-ai-keys-b { display:none; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
|
||
gap:2px 14px; margin-top:6px; padding:7px 9px; background:#0d0d0d;
|
||
border:1px solid #1c1c1c; border-radius:4px; line-height:1.6; }
|
||
.vv-ai-keys.open .vv-ai-keys-b { display:grid; }
|
||
.vv-ai-key { display:flex; align-items:baseline; gap:7px; font-size:10px; min-width:0; }
|
||
.vv-ai-key-k { display:flex; gap:3px; flex-shrink:0; }
|
||
.vv-ai-key-d { color:#5a5a5a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.vv-ai-key kbd { font-family:inherit; font-size:8px; color:#8a8a8a; background:#181818;
|
||
border:1px solid #2a2a2a; border-radius:3px; padding:1px 4px; line-height:1.5; }
|
||
|
||
/* ── 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
|
||
heading and the border, so the transcript drops its own and the hint text goes away rather
|
||
than wrapping to three lines at this width.
|
||
|
||
Surfaces are re-based on the card, not merely un-bordered. The standalone palette paints a
|
||
#0b0b0b transcript because on the AI tab it sits on the page ground with nothing beside it
|
||
to compare against; dropped into a .vv-card (#1e1e1e) that same fill reads as a hole cut in
|
||
the card rather than as part of it. Transparent here, and the remaining controls move to the
|
||
values the plugin already uses inside a card — .vv-btn-sm is #2a2a2a on #555 — so the whole
|
||
thing reads as one object.
|
||
|
||
This is the second hardcoded dark palette in the plugin, which is exactly one too many. When
|
||
theming happens it wants tokens (--vv-surface, --vv-line) defined once in varaverk.css, and
|
||
this block becomes a token swap instead of a second set of literals. */
|
||
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0;
|
||
background:transparent; }
|
||
.vv-ai-c .vv-ai-empty { padding:26px 14px; }
|
||
.vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; }
|
||
.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; }
|
||
|
||
/* 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 — in a space 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-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; }
|
||
.vv-ai-c .vv-ai-think { background:#161616; border-left-color:#3a3a3a; }
|
||
.vv-ai-c .vv-ai-body pre { background:#161616; border-color:#3a3a3a; }
|
||
.vv-ai-c .vv-ai-body code { background:#2a2a2a; }
|
||
.vv-ai-c .vv-ai-src { border-top-color:#333; }
|
||
.vv-ai-c .vv-ai-switch { border-top-color:#333; }
|
||
|
||
/* ── Stored conversations ───────────────────────────────────────────────── */
|
||
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
|
||
.vv-ai-crow { display:flex; align-items:baseline; gap:8px; padding:5px 7px; border-radius:4px;
|
||
cursor:pointer; border:1px solid transparent; }
|
||
.vv-ai-crow:hover { background:#141414; }
|
||
.vv-ai-crow.active { background:#14202c; border-color:#2d4a6a; }
|
||
.vv-ai-crow-t { font-size:11px; color:#8a8a8a; overflow:hidden; text-overflow:ellipsis;
|
||
white-space:nowrap; flex:1; min-width:0; }
|
||
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
|
||
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
|
||
.vv-ai-chat { transition:height .14s ease; }
|
||
.vv-ai-size { font-size:10px !important; line-height:1; padding:4px 10px !important;
|
||
letter-spacing:0.04em; flex-shrink:0; }
|
||
.vv-ai-grow-on { color:#9bd !important; border-color:#2d4a6a !important; }
|
||
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
|
||
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
|
||
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
|
||
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
|
||
.vv-ai-crow-x:hover { color:#e57; }
|
||
/* Hover lifts off the card rather than sinking into it. #141414 is a highlight against the AI
|
||
tab's #0e0e0e panel and a shadow against a #1e1e1e card — the same value reads as the
|
||
opposite gesture depending on what it sits on. */
|
||
.vv-ai-c .vv-ai-crow:hover { background:#2a2a2a; }
|
||
.vv-ai-c .vv-ai-crow-t { color:#ccc; }
|
||
.vv-ai-c .vv-ai-crow-m { color:#666; }
|
||
.vv-ai-c .vv-ai-crow-x { color:#666; }
|
||
.vv-ai-c .vv-ai-none { color:#666; }
|
||
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
|
||
|
||
/* ── Source overlay ─────────────────────────────────────────────────────── */
|
||
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
|
||
padding:36px; }
|
||
#vv-ai-view.open { display:block; }
|
||
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
|
||
display:flex; flex-direction:column; }
|
||
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
|
||
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
|
||
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
|
||
color:#9a9a9a; white-space:pre; }
|
||
</style>
|
||
|
||
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
|
||
<div class="vv-ai-view-box">
|
||
<div class="vv-ai-view-h">
|
||
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
|
||
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
|
||
</div>
|
||
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<?php
|
||
// The profile registry, served rather than restated. This used to be a literal table in the
|
||
// script below that had already drifted from the PHP — it knew three profiles where the server
|
||
// knew four, which is why troubleshoot could not be offered here and the Scheduler had to
|
||
// hand-roll its own labels.
|
||
vv_ai_profiles_script();
|
||
?>
|
||
<script>
|
||
(function () {
|
||
const API = '/plugins/varaverk/api/ai.php';
|
||
|
||
// The server is the authority. It re-derives depth and capability on every request regardless
|
||
// of what is here — these values draw buttons and decide whether to show the kind filter, they
|
||
// do not make policy.
|
||
const PROFILES = window.VvAiProfiles;
|
||
|
||
const esc = s => String(s == null ? '' : s)
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
const POST_HEAD = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
|
||
|
||
function ago(ts) {
|
||
if (!ts) return '—';
|
||
const d = Math.floor(Date.now()/1000) - ts;
|
||
if (d < 60) return 'just now';
|
||
if (d < 3600) return Math.floor(d/60)+'m';
|
||
if (d < 86400) return Math.floor(d/3600)+'h';
|
||
return Math.floor(d/86400)+'d';
|
||
}
|
||
|
||
// Both timestamps, because the visible one cannot answer both questions. The store is ordered
|
||
// by creation on purpose — see vv_ai_chats_list(), which keeps the visible order in step with
|
||
// the prune order so the row about to be dropped is the one at the bottom — and the column
|
||
// therefore has to show creation age or it would run out of order. "When did I last touch
|
||
// this", which is what is actually being asked of a list of saved conversations, goes here.
|
||
function chatAges(c) {
|
||
const made = 'started ' + ago(c.ts);
|
||
// A minute of slack: every conversation is written once at creation, so updated is always a
|
||
// shade later and saying so on every row would be noise.
|
||
return (c.updated && c.updated > (c.ts || 0) + 60)
|
||
? made + ', last active ' + ago(c.updated)
|
||
: made;
|
||
}
|
||
|
||
// ── Source viewer, one per page ─────────────────────────────────────────
|
||
const $g = id => document.getElementById(id);
|
||
window.vvAiOpen = function (path) {
|
||
$g('vv-ai-view-t').textContent = path;
|
||
$g('vv-ai-view-b').textContent = 'Loading…';
|
||
$g('vv-ai-view').classList.add('open');
|
||
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
|
||
.then(r => r.json())
|
||
.then(d => { $g('vv-ai-view-b').textContent = d.ok ? d.content
|
||
: (d.error || 'Could not read this file.'); })
|
||
.catch(e => { $g('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
|
||
};
|
||
window.vvAiCloseView = function () { $g('vv-ai-view').classList.remove('open'); };
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
|
||
|
||
// ── Chat instance ───────────────────────────────────────────────────────
|
||
window.__vvAiChat = window.__vvAiChat || {};
|
||
|
||
window.VvAiChat = function (o) {
|
||
const P = o.prefix;
|
||
const $ = sfx => document.getElementById(P + '-' + sfx);
|
||
const onTurn = o.onTurn || function () {};
|
||
const onChats = o.onChats || function () {};
|
||
// Called with (kind, yes, message) when the operator answers an offer the page made.
|
||
const onOffer = o.onOffer || function () {};
|
||
// Called whenever the active profile changes, however it changed — the picker, a reopened
|
||
// conversation, or the page retargeting. A page holding its own copy of "which profile" has
|
||
// no other way to stay in step with a menu it does not own.
|
||
const onProfile = o.onProfile || function () {};
|
||
// Called with the new state when the operator expands or collapses, for a page that has to
|
||
// re-lay itself out around the new height. See the grow handler.
|
||
const onResize = o.onResize || function () {};
|
||
const store = o.chats !== false;
|
||
const POLL_MS = 1200;
|
||
// Once tokens are actually arriving, 1200ms delivers them in visible lumps and the stream reads
|
||
// as stuttering rather than writing. The endpoint only reads one small file off tmpfs, so the
|
||
// extra requests are cheap — and this rate only applies while a generation is in flight.
|
||
const POLL_FAST_MS = 350;
|
||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||
|
||
// An instance already holding this prefix is a leftover from a tab swap, still holding
|
||
// timers and a busy flag over DOM nodes that no longer exist.
|
||
if (window.__vvAiChat[P]) { try { window.__vvAiChat[P].teardown(); } catch (e) {} }
|
||
|
||
let profile = PROFILES[o.profile] ? o.profile : 'varaverk';
|
||
let messages = []; // whole transcript — displayed and stored
|
||
let sendFrom = 0; // index the model is allowed to see from
|
||
let chatId = ''; // '' until the store mints one
|
||
let scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without one
|
||
let busy = false;
|
||
let lastSources = [];
|
||
let pendingTimer = null;
|
||
|
||
const chatEl = () => $('chat');
|
||
|
||
// A scroll event says the transcript moved. It does not say who moved it, and the browser
|
||
// fires an identical one either way — so syncFollow(), which answers for the operator on
|
||
// every scroll event, could answer on the strength of a scroll this component performed.
|
||
// Auto Scroll was reported as un-untickable during a stream, and this is the class of cause:
|
||
// the control cannot be authoritative while anything else is allowed to write to it.
|
||
//
|
||
// Held here rather than proven: the exact event that re-ticked it was not reproduced from the
|
||
// source, only the requirement that our own movement must never count as the operator's.
|
||
// If it turns out something else re-ticks the box, this guard is still correct and the real
|
||
// cause is still open — do not read this comment as saying the bug was diagnosed.
|
||
//
|
||
// A count, not a flag, because these nest: scroll() runs inside wrapped writes. Released on
|
||
// the next animation frame, because scroll events are dispatched in the rendering step ahead
|
||
// of requestAnimationFrame callbacks — so the guard is still up when ours arrives.
|
||
let selfMoves = 0;
|
||
function selfMove(fn) {
|
||
selfMoves++;
|
||
try { fn(); } finally { requestAnimationFrame(() => { if (selfMoves > 0) selfMoves--; }); }
|
||
}
|
||
const scroll = () => selfMove(() => { const c = chatEl(); c.scrollTop = c.scrollHeight; });
|
||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||
|
||
// ── Diff ─────────────────────────────────────────────────────────────
|
||
// Computed here from the two full texts, never asked of the model. A model asked for a unified
|
||
// diff produces a plausible-looking one with wrong line numbers and dropped context often
|
||
// enough to be useless, and it already returns the whole modified script — so the reliable
|
||
// move is to diff what it gave against what is open and show the difference ourselves.
|
||
|
||
// Guard against a pathological pair freezing the tab. 4M cells is roughly 2000x2000 lines,
|
||
// far past any script in this repo; beyond it the block is simply offered as a block.
|
||
const DIFF_CELL_MAX = 4000000;
|
||
|
||
function diffLines(A, B) {
|
||
const n = A.length, m = B.length;
|
||
|
||
// Common prefix and suffix are stripped before the matrix is built. Edits cluster in the
|
||
// middle of a file, so this usually turns a 300x300 table into something trivial.
|
||
let s = 0; while (s < n && s < m && A[s] === B[s]) s++;
|
||
let e = 0; while (e < n - s && e < m - s && A[n - 1 - e] === B[m - 1 - e]) e++;
|
||
|
||
const a = A.slice(s, n - e), b = B.slice(s, m - e);
|
||
if (a.length * b.length > DIFF_CELL_MAX) return null;
|
||
|
||
// Longest common subsequence, filled from the end so the walk forward below is greedy and
|
||
// produces the conventional "deletions before insertions" ordering.
|
||
const R = a.length, C = b.length;
|
||
const dp = [];
|
||
for (let i = 0; i <= R; i++) dp.push(new Uint32Array(C + 1));
|
||
for (let i = R - 1; i >= 0; i--) {
|
||
for (let j = C - 1; j >= 0; j--) {
|
||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1
|
||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||
}
|
||
}
|
||
|
||
const ops = [];
|
||
for (let k = 0; k < s; k++) ops.push({ t: ' ', text: A[k] });
|
||
let i = 0, j = 0;
|
||
while (i < R && j < C) {
|
||
if (a[i] === b[j]) { ops.push({ t: ' ', text: a[i] }); i++; j++; }
|
||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: '-', text: a[i] }); i++; }
|
||
else { ops.push({ t: '+', text: b[j] }); j++; }
|
||
}
|
||
while (i < R) ops.push({ t: '-', text: a[i++] });
|
||
while (j < C) ops.push({ t: '+', text: b[j++] });
|
||
for (let k = 0; k < e; k++) ops.push({ t: ' ', text: A[n - e + k] });
|
||
return ops;
|
||
}
|
||
|
||
// Changed lines, grouped with context. Runs closer together than twice the context merge into
|
||
// one hunk rather than showing the same lines as trailing context and then leading context.
|
||
const DIFF_CTX = 3;
|
||
function diffHunks(ops, ctx) {
|
||
ctx = ctx === undefined ? DIFF_CTX : ctx;
|
||
const changed = [];
|
||
ops.forEach((o, k) => { if (o.t !== ' ') changed.push(k); });
|
||
if (!changed.length) return [];
|
||
|
||
const hunks = [];
|
||
let from = changed[0], to = changed[0];
|
||
for (let x = 1; x < changed.length; x++) {
|
||
if (changed[x] - to <= ctx * 2) { to = changed[x]; continue; }
|
||
hunks.push({ from, to });
|
||
from = to = changed[x];
|
||
}
|
||
hunks.push({ from, to });
|
||
|
||
return hunks.map(h => ({
|
||
from: Math.max(0, h.from - ctx),
|
||
to: Math.min(ops.length - 1, h.to + ctx),
|
||
adds: ops.slice(h.from, h.to + 1).filter(o => o.t === '+').length,
|
||
dels: ops.slice(h.from, h.to + 1).filter(o => o.t === '-').length,
|
||
}));
|
||
}
|
||
|
||
// Rebuilds the whole file with only the selected hunks taken. Outside a selected hunk the old
|
||
// side wins (keep '-', drop '+'); inside it the new side does. That means applying one hunk
|
||
// cannot disturb a line the operator has not agreed to change — which is the entire point of
|
||
// doing this per hunk instead of replacing the file.
|
||
function applySelected(ops, hunks, selected) {
|
||
const inSel = new Array(ops.length).fill(false);
|
||
selected.forEach(hi => {
|
||
const h = hunks[hi];
|
||
if (h) for (let k = h.from; k <= h.to; k++) inSel[k] = true;
|
||
});
|
||
const out = [];
|
||
ops.forEach((o, k) => {
|
||
if (o.t === ' ') out.push(o.text);
|
||
else if (o.t === '-') { if (!inSel[k]) out.push(o.text); }
|
||
else { if (inSel[k]) out.push(o.text); }
|
||
});
|
||
return out.join('\n');
|
||
}
|
||
|
||
// Share of the smaller file that survives unchanged. A rewrite scores near zero and is offered
|
||
// as a block, because rendering a whole new script as one enormous all-added hunk is noise
|
||
// dressed as review.
|
||
const DIFF_MIN_SIM = 0.30;
|
||
function diffSimilarity(ops, A, B) {
|
||
const same = ops.reduce((n, o) => n + (o.t === ' ' ? 1 : 0), 0);
|
||
const base = Math.max(1, Math.min(A.length, B.length));
|
||
return same / base;
|
||
}
|
||
|
||
// Held per card rather than per answer. Nothing is cached across a render: every draw
|
||
// recomputes against the editor as it stands, so applying a hunk makes that hunk disappear
|
||
// from the view because it genuinely is no longer a difference.
|
||
const diffState = new WeakMap();
|
||
|
||
function drawDiff(card) {
|
||
const box = card.querySelector('[data-diff-body]');
|
||
if (!box) return;
|
||
|
||
const code = (card.querySelector('code') || {}).textContent || '';
|
||
const cur = String((o.getCompareText && o.getCompareText()) || '');
|
||
|
||
if (!cur.trim()) {
|
||
box.innerHTML = `<div class="vv-ai-none">nothing open to compare against</div>`;
|
||
return;
|
||
}
|
||
|
||
const A = cur.split('\n'), B = code.split('\n');
|
||
const ops = diffLines(A, B);
|
||
if (!ops) {
|
||
box.innerHTML = `<div class="vv-ai-none">too large to diff — use Insert instead</div>`;
|
||
return;
|
||
}
|
||
|
||
const hunks = diffHunks(ops);
|
||
if (!hunks.length) {
|
||
box.innerHTML = `<div class="vv-ai-none">identical to what is open — nothing to apply</div>`;
|
||
return;
|
||
}
|
||
|
||
const sim = diffSimilarity(ops, A, B);
|
||
diffState.set(card, { ops, hunks });
|
||
|
||
// Below the threshold this is a different file, not an edit of this one. Said plainly rather
|
||
// than rendered as one vast all-added hunk, which looks like review but reads as noise.
|
||
const warn = sim < DIFF_MIN_SIM
|
||
? `<div class="vv-ai-diff-warn">Only ${Math.round(sim * 100)}% of the open file survives —
|
||
this looks like a replacement rather than an edit. Check before applying.</div>`
|
||
: '';
|
||
|
||
box.innerHTML = warn + hunks.map((h, hi) => {
|
||
const rows = [];
|
||
for (let k = h.from; k <= h.to; k++) {
|
||
const op = ops[k];
|
||
const cls = op.t === '+' ? 'add' : op.t === '-' ? 'del' : 'ctx';
|
||
rows.push(`<div class="vv-ai-dl ${cls}"><span class="vv-ai-dm">${op.t}</span>`
|
||
+ `<span>${esc(op.text)}</span></div>`);
|
||
}
|
||
return `<div class="vv-ai-hunk">
|
||
<div class="vv-ai-hunk-h">
|
||
<span>hunk ${hi + 1} of ${hunks.length}</span>
|
||
<span class="vv-ai-hunk-n">+${h.adds} −${h.dels}</span>
|
||
<span class="vv-ai-code-sp"></span>
|
||
<button type="button" class="vv-ai-code-btn" data-hunk="${hi}">Apply</button>
|
||
</div>
|
||
${rows.join('')}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Long blocks get numbered, because "the error is on line 40" is unusable against a wall of
|
||
// unnumbered text. The number is CSS generated content on a per-line span, which keeps it out
|
||
// of textContent — so Copy and Insert return the code and never the gutter.
|
||
const CODE_NUMBER_FROM = 12;
|
||
|
||
function codeCard(lang, code) {
|
||
const lines = code.split('\n');
|
||
const n = lines.length;
|
||
const numbered = n >= CODE_NUMBER_FROM;
|
||
// Joined on the newline rather than made display:block, so <pre> supplies the line break and
|
||
// textContent comes back byte-identical to what the model wrote.
|
||
const body = numbered
|
||
? lines.map(x => `<span class="vv-ai-cl">${x}</span>`).join('\n')
|
||
: code;
|
||
|
||
let head = '<div class="vv-ai-code-h">';
|
||
head += `<span class="vv-ai-code-lang">${lang || 'text'}</span>`;
|
||
head += `<span class="vv-ai-code-n">${n} line${n === 1 ? '' : 's'}</span>`;
|
||
head += '<span class="vv-ai-code-sp"></span>';
|
||
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
|
||
// Offered wherever there is something to compare against. Whether the open file is actually
|
||
// related to this block is decided on click, from the text as it stands then — the editor
|
||
// may have been switched or edited since the answer arrived.
|
||
if (o.getCompareText && o.onReplaceCode) {
|
||
head += '<button type="button" class="vv-ai-code-btn" data-code-diff>Diff</button>';
|
||
}
|
||
// Only offered where the page can actually receive it. Without a target this would be a
|
||
// button that silently does nothing, which is worse than not having one.
|
||
if (o.onInsertCode) {
|
||
head += '<button type="button" class="vv-ai-code-btn" data-code-insert>Insert</button>';
|
||
}
|
||
head += '</div>';
|
||
|
||
return `<div class="vv-ai-code${numbered ? ' numbered' : ''}">${head}`
|
||
+ `<pre><code>${body}</code></pre></div>`;
|
||
}
|
||
|
||
// ── Minimal markdown, applied strictly after escaping ────────────────
|
||
function fmt(text) {
|
||
let h = esc(text);
|
||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => codeCard(l, c));
|
||
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
|
||
return h;
|
||
}
|
||
|
||
// ── Transcript ───────────────────────────────────────────────────────
|
||
function addUser(text) {
|
||
clearEmpty();
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
|
||
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
|
||
scroll();
|
||
}
|
||
|
||
function addPending() {
|
||
const n = el(`<div class="vv-ai-msg bot" id="${P}-pending"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
|
||
+ `<span id="${P}-phase">starting…</span>`
|
||
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div>`
|
||
// The same toggle a finished answer carries, open while it is being written.
|
||
// Wrapped, because [hidden] on either of these loses to their own display rule.
|
||
+ `<div id="${P}-think-w" hidden>`
|
||
+ `<div class="vv-ai-think-t"><span class="vv-ai-think-car">▾</span>`
|
||
+ `reasoning (<span id="${P}-think-n">0</span> chars)</div>`
|
||
+ `<div class="vv-ai-think open" id="${P}-livethink"></div></div>`
|
||
+ `<div class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></div></div>`);
|
||
chatEl().appendChild(n); scroll();
|
||
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a
|
||
// stalled turn and a slow one look identical, and the slow case here is legitimately ~40s.
|
||
const t0 = Date.now();
|
||
clearInterval(pendingTimer);
|
||
pendingTimer = setInterval(() => {
|
||
const e = $('elapsed');
|
||
if (!e) { clearInterval(pendingTimer); return; }
|
||
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
|
||
}, 1000);
|
||
}
|
||
function phase(t) { const p = $('phase'); if (p) p.textContent = t; }
|
||
|
||
// A line in the transcript that is not a turn — something the page did, said where the operator
|
||
// is already looking rather than in a banner they have to notice. Lives in the closure rather
|
||
// than only on the instance because the poll loop needs it too, and a bare note() there would
|
||
// throw: the instance object is not in scope from inside its own factory.
|
||
function noteLine(text) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
|
||
scroll();
|
||
}
|
||
|
||
// Streaming is only worth having if it does not fight the reader. Answers routinely outrun the
|
||
// panel, and the moment someone scrolls up to re-read a line, an unconditional scroll() on
|
||
// every delta drags them back down three times a second.
|
||
const nearBottom = () => {
|
||
const c = chatEl();
|
||
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
|
||
};
|
||
|
||
// Follow is the visible form of the same rule, and works exactly as the Scheduler's log panel
|
||
// does: scrolling up unticks it, returning to the bottom ticks it again, and it can be unticked
|
||
// by hand to pin the view while an answer is still arriving. Making it a control rather than
|
||
// an invisible heuristic matters because the heuristic is otherwise indistinguishable from the
|
||
// page having stopped updating.
|
||
const following = () => { const f = $('follow'); return !f || f.checked; };
|
||
|
||
function syncFollow() {
|
||
// Only a scroll the operator actually performed may answer for them. Ours are ignored, or
|
||
// the control cannot be switched off while the thing it controls is running.
|
||
if (selfMoves === 0) {
|
||
const f = $('follow');
|
||
if (f) f.checked = nearBottom();
|
||
}
|
||
syncJump();
|
||
}
|
||
|
||
// Only when following. A plain scroll() here would defeat the control it is meant to obey.
|
||
function scrollIfFollowing() { if (following()) scroll(); }
|
||
|
||
// The other half of stick-to-bottom. Scrolling up during a stream deliberately stops the
|
||
// transcript chasing you, which means text is now arriving off-screen with nothing saying so.
|
||
// The pill is that acknowledgement, and the way back.
|
||
function syncJump() {
|
||
const p = $('jump');
|
||
if (!p) return;
|
||
p.hidden = nearBottom();
|
||
}
|
||
|
||
// Rendered through the same fmt() as a finished answer, so a fence that is still being written
|
||
// formats as it arrives rather than snapping from plain text to a code block at the end. fmt
|
||
// escapes, so a half-written tag cannot break out of the bubble mid-stream.
|
||
function streamInto(text) {
|
||
const s = $('stream');
|
||
if (!s) return;
|
||
if (!text) { s.hidden = true; return; }
|
||
// Wrapped because replacing the bubble's content changes the transcript's scroll height,
|
||
// and any scroll event that results is this component's doing rather than the operator's.
|
||
// See selfMove().
|
||
selfMove(() => { s.hidden = false; s.innerHTML = fmt(text); });
|
||
scrollIfFollowing();
|
||
syncJump();
|
||
}
|
||
|
||
// Live reasoning, shown only when asked for. Rendered as plain escaped text rather than
|
||
// through fmt(): reasoning is a stream of consciousness full of half-written fences and stray
|
||
// backticks, and formatting it mid-flight produces flickering code blocks that close
|
||
// themselves a second later.
|
||
//
|
||
// Only the count is rewritten, never the toggle around it — collapsing the block mid-answer is
|
||
// a decision, and redrawing the caret three times a second would undo it.
|
||
function thinkInto(text) {
|
||
const w = $('think-w'), t = $('livethink'), n = $('think-n');
|
||
if (!w || !t) return;
|
||
const on = seeThink() && !!text;
|
||
w.hidden = !on;
|
||
if (!on) return;
|
||
// Wrapped for the same reason streamInto's rewrite is: growing this block changes the
|
||
// transcript's scroll height, and the resulting event is ours rather than the operator's.
|
||
selfMove(() => {
|
||
t.textContent = text;
|
||
if (n) n.textContent = text.length.toLocaleString();
|
||
});
|
||
scrollIfFollowing();
|
||
}
|
||
|
||
const SEE_THINK_KEY = 'vvAiSeeThink:' + P;
|
||
// Per instance, like the reasoning key beside it: the Monitor card and a tab's assistant are
|
||
// opened for different reasons and should not share one answer to "pick up where I left off".
|
||
const RESUME_KEY = 'vvAiResume:' + P;
|
||
const seeThink = () => { const c = $('see-think'); return !!c && c.checked; };
|
||
|
||
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
|
||
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
|
||
function sourcesHtml(sources) {
|
||
if (!sources || !sources.length) return '';
|
||
// Expanded by default and collapsible by choice, never the reverse. What the model was given
|
||
// is the first thing worth seeing when an answer is wrong, and hiding it behind a click makes
|
||
// a bad retrieval look like a bad model.
|
||
let h = `<div class="vv-ai-src"><div class="vv-ai-src-h" data-src-toggle>`
|
||
+ `<span class="vv-ai-src-car">▾</span>Sources (${sources.length})</div>`
|
||
+ `<div class="vv-ai-src-l">`;
|
||
sources.forEach((s, i) => {
|
||
// A web result is a page on the internet, not a file in this install: it opens in a tab
|
||
// rather than in the source viewer, it leads with its title rather than its URL, and it
|
||
// carries no retrieval score because nothing here scored it.
|
||
if (s.web && s.url) {
|
||
const label = [s.heading, s.url].filter(Boolean).join(' — ');
|
||
h += `<div class="vv-ai-src-i" data-web="${esc(s.url)}">`
|
||
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||
+ `<span class="vv-ai-src-s">web</span></div>`;
|
||
return;
|
||
}
|
||
const label = [s.path, s.section, s.heading].filter(Boolean).join(' › ');
|
||
h += `<div class="vv-ai-src-i" data-src="${esc(s.path)}">`
|
||
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
|
||
});
|
||
return h + '</div></div>';
|
||
}
|
||
|
||
function addAnswer(job) {
|
||
const p = $('pending'); if (p) p.remove();
|
||
lastSources = job.sources || [];
|
||
|
||
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
|
||
if (job.thinking) {
|
||
h += `<div class="vv-ai-think-t" data-think="1"><span class="vv-ai-think-car">▸</span>`
|
||
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
|
||
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
|
||
}
|
||
// Detected from the generated code, not from the model saying so. These scripts run as
|
||
// root on a schedule, so the banner is louder than whatever prose warning it may have added.
|
||
if (job.warnings && job.warnings.length) {
|
||
h += `<div class="vv-ai-danger"><strong>Destructive — read before running.</strong> `
|
||
+ `This script ${job.warnings.map(esc).join('; ')}. `
|
||
+ `Run any dry-run form first and check the paths are what you expect.</div>`;
|
||
}
|
||
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
|
||
h += sourcesHtml(job.sources);
|
||
const t = job.timing || {};
|
||
if (t.tok_s) {
|
||
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
|
||
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
|
||
}
|
||
const node = el(h + '</div>');
|
||
chatEl().appendChild(node);
|
||
// Measured after it is in the DOM — scrollHeight is 0 on a detached node, so a fold decided
|
||
// before appending would either never fire or fire on everything.
|
||
foldIfLong(node);
|
||
// Obeys Follow like every other write. This was the one place that did not, and it was the
|
||
// worst place for it: unticking the box to hold your place while an answer arrives, only to
|
||
// be dragged to the bottom the instant it lands.
|
||
scrollIfFollowing();
|
||
syncJump();
|
||
}
|
||
|
||
// A long answer buries the composer on a 15" panel, and the composer is where the next thing
|
||
// happens. Folded to a readable height with the control always visible — never hover-revealed,
|
||
// because the surfaces this runs on have no pointer near them.
|
||
const FOLD_PX = 420;
|
||
function foldIfLong(node) {
|
||
const body = node.querySelector('.vv-ai-body');
|
||
if (!body || body.scrollHeight <= FOLD_PX) return;
|
||
body.classList.add('vv-ai-fold');
|
||
body.insertAdjacentHTML('afterend',
|
||
`<button type="button" class="vv-ai-lnk vv-ai-more" data-more>Show the rest`
|
||
+ ` (${Math.round(body.scrollHeight / 20)} lines)</button>`);
|
||
}
|
||
|
||
function addError(msg) {
|
||
const p = $('pending'); if (p) p.remove();
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
|
||
scroll();
|
||
}
|
||
|
||
// One listener for the whole transcript, covering nodes that do not exist yet.
|
||
chatEl().addEventListener('click', e => {
|
||
// Drives both the live block and a finished answer's — they are the same control now, so
|
||
// one handler owns the caret and neither can drift into disagreeing about which way it points.
|
||
const think = e.target.closest('.vv-ai-think-t');
|
||
if (think) {
|
||
const open = think.nextElementSibling.classList.toggle('open');
|
||
const car = think.querySelector('.vv-ai-think-car');
|
||
if (car) car.textContent = open ? '▾' : '▸';
|
||
return;
|
||
}
|
||
const src = e.target.closest('.vv-ai-src-i');
|
||
// Through vvSafeUrl, which is the global that exists precisely so a URL from outside this
|
||
// machine cannot become a javascript: href. The server drops anything that is not http(s)
|
||
// as well; this is the second of the two, not the only one.
|
||
if (src && src.dataset.web) {
|
||
const u = vvSafeUrl(src.dataset.web);
|
||
if (u) window.open(u, '_blank', 'noopener,noreferrer');
|
||
return;
|
||
}
|
||
// A page that can do something better with a source than show it gets first refusal — the
|
||
// Scheduler opens scripts in its editor, where they can actually be changed. Everything
|
||
// else falls through to the read-only viewer.
|
||
if (src && src.dataset.src) {
|
||
if (o.onOpenSource) o.onOpenSource(src.dataset.src);
|
||
else vvAiOpen(src.dataset.src);
|
||
return;
|
||
}
|
||
const cite = e.target.closest('.vv-ai-cite');
|
||
if (cite) {
|
||
const s = lastSources[Number(cite.dataset.cite) - 1];
|
||
if (s && s.web && s.url) {
|
||
const u = vvSafeUrl(s.url);
|
||
if (u) window.open(u, '_blank', 'noopener,noreferrer');
|
||
return;
|
||
}
|
||
// Same routing as a source row — a citation and the row it refers to must not behave
|
||
// differently, or clicking [3] and clicking source 3 land in different places.
|
||
if (s && s.path) {
|
||
if (o.onOpenSource) o.onOpenSource(s.path);
|
||
else vvAiOpen(s.path);
|
||
}
|
||
return;
|
||
}
|
||
const more = e.target.closest('[data-more]');
|
||
if (more) {
|
||
const body = more.previousElementSibling;
|
||
if (body) body.classList.remove('vv-ai-fold');
|
||
more.remove();
|
||
return;
|
||
}
|
||
|
||
const stog = e.target.closest('[data-src-toggle]');
|
||
if (stog) {
|
||
const box = stog.parentElement;
|
||
box.classList.toggle('collapsed');
|
||
const car = stog.querySelector('.vv-ai-src-car');
|
||
if (car) car.textContent = box.classList.contains('collapsed') ? '▸' : '▾';
|
||
return;
|
||
}
|
||
|
||
const off = e.target.closest('[data-offer]');
|
||
if (off) { answerOffer(+off.dataset.offer, off.dataset.yes === '1'); return; }
|
||
|
||
// Code-card buttons. The text is read from the DOM rather than carried in a data attribute:
|
||
// a script full of quotes and backslashes interpolated into an attribute is an escaping bug
|
||
// waiting to happen, and textContent already holds exactly what the model wrote.
|
||
// Apply one hunk. The editor is rewritten whole — the page owns splicing it in with undo —
|
||
// and then the diff is redrawn against the new content, so what was just applied vanishes
|
||
// from the list because it is no longer a difference.
|
||
const hb = e.target.closest('[data-hunk]');
|
||
if (hb) {
|
||
const card = hb.closest('.vv-ai-code');
|
||
const st = diffState.get(card);
|
||
if (!st) return;
|
||
const next = applySelected(st.ops, st.hunks, new Set([Number(hb.dataset.hunk)]));
|
||
if (o.onReplaceCode) o.onReplaceCode(next);
|
||
drawDiff(card);
|
||
return;
|
||
}
|
||
|
||
const dbtn = e.target.closest('[data-code-diff]');
|
||
if (dbtn) {
|
||
const card = dbtn.closest('.vv-ai-code');
|
||
const open = card.classList.toggle('diffing');
|
||
dbtn.classList.toggle('ok', open);
|
||
dbtn.textContent = open ? 'Code' : 'Diff';
|
||
let box = card.querySelector('[data-diff-body]');
|
||
if (!box) {
|
||
card.insertAdjacentHTML('beforeend', '<div class="vv-ai-diff" data-diff-body></div>');
|
||
box = card.querySelector('[data-diff-body]');
|
||
}
|
||
box.hidden = !open;
|
||
if (open) drawDiff(card);
|
||
return;
|
||
}
|
||
|
||
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
|
||
if (cbtn) {
|
||
const card = cbtn.closest('.vv-ai-code');
|
||
const code = card ? (card.querySelector('code') || {}).textContent || '' : '';
|
||
if (!code) return;
|
||
|
||
if (cbtn.hasAttribute('data-code-insert')) {
|
||
if (o.onInsertCode) o.onInsertCode(code);
|
||
flashBtn(cbtn, 'Inserted');
|
||
return;
|
||
}
|
||
|
||
// navigator.clipboard needs a secure context. Unraid is routinely reached over plain http
|
||
// on the LAN, where it is simply undefined — so the textarea fallback is the path that
|
||
// actually runs here, not a legacy nicety.
|
||
const done = () => flashBtn(cbtn, 'Copied');
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
navigator.clipboard.writeText(code).then(done).catch(() => copyFallback(code, done));
|
||
} else {
|
||
copyFallback(code, done);
|
||
}
|
||
}
|
||
});
|
||
|
||
function copyFallback(text, done) {
|
||
const t = document.createElement('textarea');
|
||
t.value = text;
|
||
// Off-screen rather than hidden: display:none and visibility:hidden are not selectable, so
|
||
// execCommand copies nothing from them.
|
||
t.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
|
||
document.body.appendChild(t);
|
||
t.select();
|
||
try { document.execCommand('copy'); done(); } catch (_) {}
|
||
t.remove();
|
||
}
|
||
|
||
// The label is stashed on the element the first time and never re-read. Capturing it per
|
||
// press meant a second click inside the 1100ms window recorded "Copied" as the text to
|
||
// restore, and the button then read "Copied" for the rest of the page's life. The pending
|
||
// timer is cleared for the same reason: two in flight restore in the wrong order.
|
||
function flashBtn(btn, msg) {
|
||
if (btn.dataset.label === undefined) btn.dataset.label = btn.textContent;
|
||
clearTimeout(+btn.dataset.flash || 0);
|
||
btn.textContent = msg;
|
||
btn.classList.add('ok');
|
||
btn.dataset.flash = setTimeout(() => {
|
||
btn.textContent = btn.dataset.label;
|
||
btn.classList.remove('ok');
|
||
delete btn.dataset.flash;
|
||
}, 1100);
|
||
}
|
||
|
||
// ── Last-exchange controls ───────────────────────────────────────────
|
||
// Shown only when there is a completed exchange and nothing in flight. Hidden rather than
|
||
// disabled: a disabled control still occupies a line on a 15" panel to advertise something
|
||
// that cannot be done.
|
||
function syncLast() {
|
||
const box = $('last');
|
||
if (!box) return;
|
||
box.hidden = busy || lastUserIndex() < 0;
|
||
}
|
||
|
||
// Index of the most recent user message. Everything from it to the end is one exchange —
|
||
// the question, its answer, and any offer that answer carried.
|
||
function lastUserIndex() {
|
||
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user') return i;
|
||
return -1;
|
||
}
|
||
|
||
// Truncates to just before the last question and returns what it was. The DOM is rebuilt from
|
||
// messages rather than patched, because an error bubble renders as a message but was never
|
||
// stored — so anything counting elements would drift the first time a turn failed.
|
||
function dropLastExchange() {
|
||
const i = lastUserIndex();
|
||
if (i < 0) return '';
|
||
const q = messages[i].content;
|
||
messages.length = i;
|
||
render();
|
||
save();
|
||
syncLast();
|
||
return q;
|
||
}
|
||
|
||
function lastAction(what) {
|
||
if (busy) return;
|
||
if (what === 'edit') {
|
||
const q = dropLastExchange();
|
||
if (!q) return;
|
||
const inp = $('input');
|
||
if (inp) { inp.value = q; inp.focus(); inp.setSelectionRange(q.length, q.length); saveDraft(); }
|
||
return;
|
||
}
|
||
if (what === 'drop') { dropLastExchange(); return; }
|
||
if (what === 'retry') {
|
||
const q = dropLastExchange();
|
||
if (!q) return;
|
||
const inp = $('input');
|
||
if (inp) inp.value = q;
|
||
send();
|
||
}
|
||
}
|
||
|
||
// ── Draft persistence ────────────────────────────────────────────────
|
||
// Unraid swaps tabs by replacing the DOM, which destroys anything typed and not sent. Losing a
|
||
// carefully worded question to a stray tab click is the kind of small loss that stops people
|
||
// using a tool. Keyed per instance so the three surfaces do not share one draft.
|
||
const DRAFT_KEY = 'vvAiDraft:' + P;
|
||
function saveDraft() {
|
||
try {
|
||
const v = ($('input') || {}).value || '';
|
||
if (v.trim()) localStorage.setItem(DRAFT_KEY, v); else localStorage.removeItem(DRAFT_KEY);
|
||
} catch (_) {}
|
||
}
|
||
function restoreDraft() {
|
||
try {
|
||
const v = localStorage.getItem(DRAFT_KEY);
|
||
const inp = $('input');
|
||
// Never over an existing value: resume may have put something there first.
|
||
if (v && inp && !inp.value) inp.value = v;
|
||
} catch (_) {}
|
||
}
|
||
function clearDraft() { try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} }
|
||
|
||
// ── Slash commands ───────────────────────────────────────────────────
|
||
// Typed rather than clicked, for the panels that live on a screen with no pointer near them.
|
||
// Everything here is also reachable by mouse or Alt-key; this is a third route to the same
|
||
// controls, never the only route to any of them.
|
||
function runCommand(raw) {
|
||
const parts = raw.slice(1).trim().split(/\s+/);
|
||
const cmd = (parts.shift() || '').toLowerCase();
|
||
const arg = parts.join(' ');
|
||
const clear = () => { $('input').value = ''; clearDraft(); };
|
||
|
||
// Toggles a checkbox the page owns, if it gave us one. Reports the resulting state rather
|
||
// than the requested one, so a command against a control this profile does not have says so.
|
||
const flip = (elId, label) => {
|
||
const c = elId ? document.getElementById(elId) : null;
|
||
if (!c) { noteLine(label + ' is not available on this profile.'); return; }
|
||
c.checked = !c.checked;
|
||
noteLine(label + (c.checked ? ' on' : ' off') + ' for the next question.');
|
||
};
|
||
|
||
switch (cmd) {
|
||
case 'new': clear(); newChat(); $('input').focus(); return;
|
||
case 'saved': { clear(); const hb = $('hist-b'); if (hb) hb.click(); return; }
|
||
case 'web': clear(); flip(o.webEl, 'Web search'); return;
|
||
case 'think': clear(); flip(o.thinkEl, 'Reasoning'); return;
|
||
case 'retry': clear(); lastAction('retry'); return;
|
||
case 'edit': clear(); lastAction('edit'); return;
|
||
|
||
case 'profile': {
|
||
clear();
|
||
// Read from the rendered menu rather than a second list in JS — one source of truth for
|
||
// which profiles exist, and it cannot drift from what the picker offers.
|
||
const opts = Array.from(document.querySelectorAll(`#${P}-menu [data-prof]`))
|
||
.map(b => b.dataset.prof);
|
||
if (!arg) { noteLine('Profiles: ' + opts.join(', ')); return; }
|
||
const want = opts.find(x => x.toLowerCase() === arg.toLowerCase())
|
||
|| opts.find(x => x.toLowerCase().startsWith(arg.toLowerCase()));
|
||
if (!want) { noteLine('No profile matching "' + arg + '". Try: ' + opts.join(', ')); return; }
|
||
setProfile(want);
|
||
noteLine('Profile is now ' + want + '.');
|
||
return;
|
||
}
|
||
|
||
case 'scope': {
|
||
clear();
|
||
const s = (typeof o.scope === 'function' ? o.scope() : (o.scope || ''));
|
||
noteLine(s ? ('Scoped to ' + s) : 'Not scoped to anything in particular.');
|
||
return;
|
||
}
|
||
|
||
case 'help': case '?': {
|
||
clear();
|
||
noteLine('/new /saved /retry /edit /profile [name] /scope /web /think /help');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Unknown commands are not sent to the model. A typo'd command is a typo, and answering it
|
||
// as a question wastes 40 seconds to say it does not understand.
|
||
noteLine('No such command: /' + cmd + ' — try /help');
|
||
$('input').value = '';
|
||
clearDraft();
|
||
}
|
||
|
||
// ── Ask / poll ───────────────────────────────────────────────────────
|
||
function send() {
|
||
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
|
||
// loop that stopped, a tab left open across a deploy — would otherwise make every later
|
||
// click a no-op with the previous "starting…" still on screen, which reads as a hang that
|
||
// produces no request and therefore no server-side trace at all.
|
||
if (busy) {
|
||
addError('A previous question is still marked in-flight, so this one was not sent. '
|
||
+ 'Reload the tab to reset it.');
|
||
return;
|
||
}
|
||
const q = $('input').value.trim();
|
||
if (!q) return;
|
||
|
||
// Slash commands are handled before anything else, so one is never sent to the model as a
|
||
// question. Checked ahead of beforeSend too: a page claiming typed text wants questions,
|
||
// not "/new".
|
||
//
|
||
// The pattern is deliberately strict — a bare word of letters, then whitespace or the end.
|
||
// "/mnt/user/appdata is filling up" is a perfectly ordinary question on this machine, and a
|
||
// looser "starts with a slash" test swallowed it as a command.
|
||
if (/^\/[a-z?]+(\s|$)/i.test(q)) { runCommand(q); return; }
|
||
|
||
// A page may claim what was typed instead of asking it. The Scheduler does this once, after
|
||
// an offer is accepted, when the next thing typed is the fix being recorded rather than a
|
||
// question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter
|
||
// both land here directly and a wrapper would only catch the ones routed through it.
|
||
if (o.beforeSend && o.beforeSend(q)) { $('input').value = ''; return; }
|
||
|
||
busy = true;
|
||
// Ask becomes Stop rather than greying out. The one stretch of the interaction that takes
|
||
// 25-75s is exactly when the operator most wants a control, and the composer row has no
|
||
// room for a fourth button that is dead 95% of the time.
|
||
setSendMode('stop');
|
||
// Asking is an act of attention: a new turn returns to the newest line and follows it,
|
||
// whatever was left unticked from reading back through the previous one. Without this, a
|
||
// question asked after scrolling up would stream in entirely off screen.
|
||
const followBox = $('follow'); if (followBox) followBox.checked = true;
|
||
addUser(q);
|
||
$('input').value = '';
|
||
clearDraft();
|
||
syncLast();
|
||
addPending();
|
||
|
||
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
|
||
// installed elsewhere on the page — would escape the promise chain entirely and leave the
|
||
// pending indicator up forever with nothing logged anywhere.
|
||
// URLSearchParams, not FormData. A multipart POST to this endpoint hangs with no status
|
||
// ever returned: the request leaves the browser with a valid token and correct body and
|
||
// never reaches PHP — no CSRF termination, no fatal, no entry log.
|
||
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||
const thinkEl = o.thinkEl ? document.getElementById(o.thinkEl) : null;
|
||
const webEl = o.webEl ? document.getElementById(o.webEl) : null;
|
||
|
||
let res;
|
||
try {
|
||
const body = new URLSearchParams({
|
||
action: 'ask',
|
||
profile: profile,
|
||
question: q,
|
||
history: JSON.stringify(sendable()),
|
||
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
|
||
// A checkbox where the page offers one, otherwise whatever the page decides from the
|
||
// profile, otherwise on. The Scheduler reasons only when diagnosing: working out what a
|
||
// log means is worth waiting ~15s for, and a lookup like "what does this setting do" is
|
||
// not — an inline answer that stalls reads as broken.
|
||
think: (typeof o.think === 'function' ? o.think(profile)
|
||
: thinkEl ? thinkEl.checked : true) ? '1' : '0',
|
||
// Only sent when the profile in force actually holds the capability, so ticking the box
|
||
// and then switching to the assistant cannot send a question about this machine to a
|
||
// search engine. The worker checks the same thing again — this is the courtesy, not
|
||
// the control.
|
||
web: (PROFILES[profile] && PROFILES[profile].web && webEl && webEl.checked)
|
||
? '1' : '0',
|
||
});
|
||
res = fetch(API, { method: 'POST', headers: POST_HEAD, body });
|
||
} catch (e) {
|
||
addError('Could not send the request: ' + (e && e.message ? e.message : e)
|
||
+ ' — this failed in the browser before reaching the server.');
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
res.then(r => r.text().then(t => ({ status: r.status, text: t })))
|
||
.then(({ status, text }) => {
|
||
if (!text.trim()) {
|
||
// The CSRF prepend terminates with an empty body, so this is the shape that failure
|
||
// takes. Naming it beats a bare JSON parse error.
|
||
addError('Empty response (HTTP ' + status + '). This usually means the request was '
|
||
+ 'rejected before the endpoint ran — check the CSRF token shim.');
|
||
finish(); return;
|
||
}
|
||
let d;
|
||
try { d = JSON.parse(text); }
|
||
catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160));
|
||
finish(); return; }
|
||
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
|
||
messages.push({ role: 'user', content: q });
|
||
// Held for Stop. Set before the first poll so a press in the first second has a target.
|
||
curToken = d.token;
|
||
poll(d.token, Date.now());
|
||
})
|
||
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
|
||
}
|
||
|
||
// What the model may see: never across a profile switch, and never deeper than this
|
||
// profile's window. api/ai.php trims again regardless — this is the client half of a cap
|
||
// that exists in both places on purpose.
|
||
function sendable() {
|
||
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
|
||
// Reduced to role and content. Messages carry local bookkeeping now — an offer's state is
|
||
// ours, not something the model should be reading back as part of the conversation.
|
||
return messages.slice(floor).map(m => ({ role: m.role, content: m.content }));
|
||
}
|
||
|
||
function finish() {
|
||
busy = false;
|
||
curToken = null;
|
||
setSendMode('ask');
|
||
clearInterval(pendingTimer);
|
||
syncLast();
|
||
}
|
||
|
||
// The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish()
|
||
// so a Stop pressed against an already-terminal job is impossible rather than merely harmless.
|
||
let curToken = null;
|
||
|
||
function setSendMode(mode) {
|
||
const b = $('send');
|
||
if (!b) return;
|
||
const stopping = (mode === 'stop');
|
||
b.disabled = false;
|
||
b.textContent = stopping ? 'Stop' : 'Ask';
|
||
b.classList.toggle('vv-ai-stop', stopping);
|
||
}
|
||
|
||
function stopTurn() {
|
||
if (!curToken) return;
|
||
const t = curToken;
|
||
// Disabled immediately: the kill is quick but the poll that renders the result is up to
|
||
// POLL_FAST_MS away, and a second press in that window would signal a dead pid.
|
||
const b = $('send'); if (b) b.disabled = true;
|
||
phase('stopping…');
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'stop', token: t }) })
|
||
.then(r => r.json())
|
||
.catch(() => {});
|
||
// Nothing is rendered from the response. The poll already owns turning a terminal state into
|
||
// a message, and having two paths do it is how a turn ends up in the transcript twice.
|
||
}
|
||
|
||
function poll(token, started) {
|
||
if (Date.now() - started > POLL_CEIL) {
|
||
addError('Timed out waiting for a response.'); finish(); return;
|
||
}
|
||
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
|
||
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
|
||
const j = d.job || {};
|
||
// Stopped is terminal and keeps whatever had been written. A turn cancelled at 80% is
|
||
// usually cancelled because there was already enough on screen, so discarding it would
|
||
// punish the operator for the one control that exists to save them time.
|
||
if (j.status === 'stopped') {
|
||
if ((j.answer || '').trim()) {
|
||
addAnswer(j);
|
||
messages.push({ role: 'assistant', content: j.answer });
|
||
noteLine('Stopped — the part already written is kept.');
|
||
} else {
|
||
const p = $('pending'); if (p) p.remove();
|
||
noteLine('Stopped before anything was written.');
|
||
}
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||
finish();
|
||
save();
|
||
return;
|
||
}
|
||
|
||
if (j.status === 'done') {
|
||
addAnswer(j);
|
||
messages.push({ role: 'assistant', content: j.answer });
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||
finish();
|
||
save();
|
||
// The answer is passed so a page can react to what was said, not just that a turn
|
||
// happened — the Scheduler uses it to spot a code block worth offering to the editor.
|
||
// Handlers that take no argument are unaffected.
|
||
onTurn(j.answer);
|
||
return;
|
||
}
|
||
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||
|
||
// Partial text arrives on the same envelope as the status, so the transcript fills in
|
||
// without a second channel. Empty while the model is still reasoning — thinking_chars is
|
||
// what moves then, and a page watching only partial would look wedged for ~15s.
|
||
if (j.status === 'generating') {
|
||
streamInto(j.partial || '');
|
||
thinkInto(j.thinking || '');
|
||
}
|
||
|
||
phase(j.status === 'generating'
|
||
? (j.partial
|
||
? 'writing… (' + ((j.sources||[]).length) + ' sources)'
|
||
: (j.thinking_chars
|
||
? 'reasoning… (' + j.thinking_chars.toLocaleString() + ' chars)'
|
||
: 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'))
|
||
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||
setTimeout(() => poll(token, started),
|
||
j.status === 'generating' ? POLL_FAST_MS : POLL_MS);
|
||
}).catch(e => { addError('Poll failed: ' + e); finish(); });
|
||
}
|
||
|
||
// ── Storage ──────────────────────────────────────────────────────────
|
||
// Fire and forget, deliberately. A store that cannot be written is not a reason to lose the
|
||
// answer that is already on screen, and the list refresh below reveals the failure anyway.
|
||
function save() {
|
||
if (!store || !messages.length) return;
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({
|
||
action: 'chat_save', id: chatId, profile,
|
||
scope: (typeof o.scope === 'function' ? o.scope() : (o.scope || '')),
|
||
messages: JSON.stringify(messages),
|
||
}) })
|
||
.then(r => r.json())
|
||
.then(d => { if (d.ok) { chatId = d.id; onChats(chatId); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
|
||
// A reopened conversation renders as plain turns. Sources, reasoning and timings are not
|
||
// stored: they describe one generation, and redrawing them beside a transcript that may be
|
||
// continued under a different profile would be citing evidence for an answer that is no
|
||
// longer being made.
|
||
function render() {
|
||
const c = chatEl();
|
||
if (!messages.length) { reset(); return; }
|
||
c.innerHTML = '';
|
||
messages.forEach((m, i) => {
|
||
if (m.role === 'user') { addUser(m.content); return; }
|
||
const node = el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
|
||
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`);
|
||
c.appendChild(node);
|
||
// Folded here too, not only on arrival. A reopened thread carrying several long answers
|
||
// is exactly the case the fold exists for — it is what buries the composer on a 15"
|
||
// panel. Measured after the append, because scrollHeight is 0 on a detached node.
|
||
foldIfLong(node);
|
||
});
|
||
scroll();
|
||
syncLast();
|
||
}
|
||
|
||
// ── Offers ───────────────────────────────────────────────────────────
|
||
// An assistant message that carries a decision rather than only text. The answer is recorded
|
||
// on the message itself, so a conversation reopened tomorrow shows what was decided instead of
|
||
// asking again — and an offer left unanswered is still answerable, which a transient banner
|
||
// would not be.
|
||
function offerHtml(off, i) {
|
||
if (off.state !== 'open') {
|
||
return `<div class="vv-ai-offer-done" data-offer-row="${i}">`
|
||
+ (off.state === 'taken' ? 'yes' : 'no thanks') + `</div>`;
|
||
}
|
||
return `<div class="vv-ai-offer" data-offer-row="${i}">`
|
||
+ `<button class="vv-ai-btn" type="button" data-offer="${i}" data-yes="1">Yes</button>`
|
||
+ `<button class="vv-ai-btn ghost" type="button" data-offer="${i}" data-yes="0">No</button>`
|
||
+ `</div>`;
|
||
}
|
||
|
||
// Appended, not rendered. render() rebuilds the transcript as plain turns and would strip the
|
||
// sources and reasoning off the answer the operator is looking at — see the note under
|
||
// loadChat. An offer arrives after an answer, so that is exactly when it must not happen.
|
||
function offer(kind, text) {
|
||
const i = messages.length;
|
||
messages.push({ role: 'assistant', content: text, offer: { kind: kind, state: 'open' } });
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body">${fmt(text)}</div>`
|
||
+ offerHtml(messages[i].offer, i) + `</div>`));
|
||
scroll();
|
||
save();
|
||
}
|
||
|
||
function answerOffer(i, yes) {
|
||
const m = messages[i];
|
||
if (!m || !m.offer || m.offer.state !== 'open') return;
|
||
m.offer.state = yes ? 'taken' : 'declined';
|
||
const row = chatEl().querySelector('[data-offer-row="' + i + '"]');
|
||
if (row) row.outerHTML = offerHtml(m.offer, i);
|
||
save();
|
||
onOffer(m.offer.kind, yes, m);
|
||
}
|
||
|
||
|
||
function reset() {
|
||
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
|
||
// An emptied transcript has no last exchange. render() funnels here when messages run out,
|
||
// and New Chat calls it directly, so both paths are covered by putting it here.
|
||
syncLast();
|
||
}
|
||
|
||
function loadChat(id) {
|
||
fetch(API + '?action=chat_get&id=' + encodeURIComponent(id)).then(r => r.json()).then(d => {
|
||
if (!d.ok) { addError(d.error || 'Could not open that conversation'); return; }
|
||
const c = d.chat || {};
|
||
messages = Array.isArray(c.messages) ? c.messages : [];
|
||
// Zero, not the length: reopening is meant to continue the thread, and a floor at the
|
||
// end would send the model an empty history for a transcript full of context.
|
||
sendFrom = 0;
|
||
chatId = c.id || '';
|
||
lastSources = [];
|
||
if (PROFILES[c.profile]) applyProfile(c.profile);
|
||
render();
|
||
onChats(chatId);
|
||
}).catch(e => addError('Could not open that conversation: ' + e));
|
||
}
|
||
|
||
function newChat() {
|
||
messages = []; sendFrom = 0; chatId = ''; lastSources = [];
|
||
reset();
|
||
onChats('');
|
||
}
|
||
|
||
// ── Profiles ─────────────────────────────────────────────────────────
|
||
function applyProfile(p) {
|
||
profile = p;
|
||
const chipL = $('chip-l');
|
||
// With a subject, the chip states both — the contract and the thing being asked about — and
|
||
// uses the profile's short name to keep the pair readable in a narrow bar. That pairing is
|
||
// load-bearing on the Scheduler: if the operator can see what it thinks it is looking at, a
|
||
// wrong inference costs a glance instead of a confidently wrong answer.
|
||
if (chipL) {
|
||
const def = PROFILES[p];
|
||
chipL.textContent = scopeLabel
|
||
? ((def && def.short ? def.short : p) + ' · ' + scopeLabel)
|
||
: (def ? def.label : p);
|
||
}
|
||
const menu = $('menu');
|
||
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.prof === p));
|
||
// 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';
|
||
onProfile(p);
|
||
}
|
||
|
||
// Switching moves the floor rather than clearing the transcript. Carrying cited,
|
||
// retrieval-grounded turns into a mode with no retrieval makes the model keep referring to
|
||
// sources it can no longer see; erasing what the operator just read to avoid that is worse.
|
||
function setProfile(p) {
|
||
if (!PROFILES[p] || p === profile) return;
|
||
applyProfile(p);
|
||
if (messages.length > sendFrom) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(PROFILES[p].label)
|
||
+ ' — earlier turns are no longer carried</div>'));
|
||
scroll();
|
||
}
|
||
sendFrom = messages.length;
|
||
$('input').focus();
|
||
}
|
||
|
||
// 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.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;
|
||
closeMenus();
|
||
setProfile(b.dataset.prof);
|
||
});
|
||
}
|
||
|
||
// 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 || {};
|
||
|
||
// Filters the rows already fetched rather than asking the endpoint again — the store is
|
||
// small, it is all in hand, and a keystroke should not cost a request.
|
||
const rowHtml = 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)}" title="${esc(chatAges(c))}">`
|
||
+ `<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>`;
|
||
};
|
||
|
||
// Title and scope, because those are what a conversation is remembered by — "the one
|
||
// about the rsync stall" or "the one pointed at daily_sync".
|
||
const match = (c, q) =>
|
||
(String(c.title || '') + ' ' + String(c.scope || '') + ' ' + String(c.profile || ''))
|
||
.toLowerCase().includes(q);
|
||
|
||
const draw = q => {
|
||
const hits = q ? rows.filter(c => match(c, q)) : rows;
|
||
const list = histMenu.querySelector('[data-hist-rows]');
|
||
if (!list) return;
|
||
list.innerHTML = hits.length
|
||
? hits.map(rowHtml).join('')
|
||
: `<div class="vv-ai-hist-msg">nothing matching “${esc(q)}”</div>`;
|
||
};
|
||
|
||
// The box only earns its line once there is enough to sift through.
|
||
const searchable = rows.length >= 6;
|
||
histMenu.innerHTML =
|
||
(searchable ? `<input type="text" class="vv-ai-hist-f" data-hist-filter
|
||
placeholder="filter ${rows.length} conversations…">` : '')
|
||
+ `<div data-hist-rows></div>`;
|
||
draw('');
|
||
|
||
if (searchable) {
|
||
const f = histMenu.querySelector('[data-hist-filter]');
|
||
f.addEventListener('input', () => draw(f.value.trim().toLowerCase()));
|
||
// Escape clears the filter before it closes the menu — one key, one step back.
|
||
f.addEventListener('keydown', ev => {
|
||
if (ev.key === 'Escape' && f.value !== '') { ev.stopPropagation(); f.value = ''; draw(''); }
|
||
});
|
||
f.focus();
|
||
}
|
||
}).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 ───────────────────────────────────────────────────────────
|
||
// Ctrl+Enter is not bound here. It lives with the rest of the shortcuts on the wrapper below,
|
||
// and a second binding on the input would send the same question twice — the second landing
|
||
// on the busy guard and reporting the first as stuck.
|
||
// One button, two jobs — which one is decided by busy rather than by what the label happens to
|
||
// say, so a stale label can never send a question into a turn already running.
|
||
$('send').addEventListener('click', () => { busy ? stopTurn() : send(); });
|
||
|
||
const lastBox = $('last');
|
||
if (lastBox) {
|
||
lastBox.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-last]');
|
||
if (b) lastAction(b.dataset.last);
|
||
});
|
||
}
|
||
|
||
// input, not change: change only fires on blur, and a tab swap does not blur first.
|
||
$('input').addEventListener('input', saveDraft);
|
||
restoreDraft();
|
||
syncLast();
|
||
|
||
const jumpEl = $('jump');
|
||
if (jumpEl) {
|
||
// Ticks Follow again on the way down, because arriving at the bottom is what Follow means.
|
||
jumpEl.addEventListener('click', () => { scroll(); syncFollow(); });
|
||
}
|
||
// passive: this only reads scroll position, so it must never delay the scroll itself.
|
||
chatEl().addEventListener('scroll', syncFollow, { passive: true });
|
||
|
||
const followEl = $('follow');
|
||
if (followEl) {
|
||
// Ticking it by hand is a request to be at the newest line now, not merely to be taken
|
||
// there by the next delta — which on a finished conversation is never.
|
||
followEl.addEventListener('change', () => { if (followEl.checked) scroll(); syncJump(); });
|
||
}
|
||
|
||
const seeEl = $('see-think');
|
||
if (seeEl) {
|
||
// A display preference, so it is remembered per instance — the Monitor card and the AI tab
|
||
// are watched in different circumstances and should not share one answer.
|
||
try { seeEl.checked = localStorage.getItem(SEE_THINK_KEY) === '1'; } catch (_) {}
|
||
seeEl.addEventListener('change', () => {
|
||
try { localStorage.setItem(SEE_THINK_KEY, seeEl.checked ? '1' : '0'); } catch (_) {}
|
||
// Takes effect on the turn in flight, not just the next one — unticking it mid-answer is
|
||
// usually someone deciding they have seen enough. The wrapper is what hides, for the same
|
||
// reason thinkInto uses it: [hidden] on the pair itself loses to their own display rule.
|
||
const w = $('think-w'), t = $('livethink');
|
||
if (w) w.hidden = !seeEl.checked || !t || !t.textContent;
|
||
});
|
||
}
|
||
// Remembered like the reasoning checkbox beside it. It was not, and the two sitting together
|
||
// behaving differently is its own bug: neither has a Save button, so one that forgets reads
|
||
// as one that failed to save. Ticked, closed and reopened, it had reverted — which is why no
|
||
// web search had ever actually run.
|
||
//
|
||
// Remembering a tick is not the same as defaulting it on. The conf ships this off because
|
||
// sending a question outside the house is the operator's decision to make rather than one to
|
||
// inherit; honouring that decision until they change it is the point, not a weakening of it.
|
||
const webEl = o.webEl ? document.getElementById(o.webEl) : null;
|
||
if (webEl) {
|
||
const WEB_KEY = 'vvAiWeb:' + P;
|
||
try { webEl.checked = localStorage.getItem(WEB_KEY) === '1'; } catch (_) {}
|
||
webEl.addEventListener('change', () => {
|
||
try { localStorage.setItem(WEB_KEY, webEl.checked ? '1' : '0'); } catch (_) {}
|
||
});
|
||
}
|
||
|
||
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
|
||
|
||
// ── Expand / collapse ────────────────────────────────────────────────
|
||
// Both heights come from the markup, so the page decides them and this only switches
|
||
// between them.
|
||
//
|
||
// Deliberately not remembered. The only instance with this control lives on the Monitor
|
||
// dashboard, which is a page you open to glance at something else — a chat left expanded
|
||
// days ago would push every card below it down on each load, and the cause would be
|
||
// invisible to whoever eventually wondered why. Expanding is for the reading being done
|
||
// now, so the page always opens collapsed.
|
||
//
|
||
// Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript
|
||
// scrolled where it was, which puts the newest answer off-screen at the exact moment you
|
||
// asked for more room to read it.
|
||
// The two heights live on the element as data attributes rather than in a closure, so a page
|
||
// whose sizes are not knowable when the markup is written can rewrite them later — the
|
||
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
|
||
// number that only exists after layout and changes on every resize. See setHeights().
|
||
// Two independent pieces of state, not one three-way. `big` is whether the conversation is
|
||
// expanded — an action, taken often. `large` is how much expanded is worth — a setting, chosen
|
||
// once. Collapsing and then expanding again returns to the size you picked rather than
|
||
// resetting it, which is the whole reason they are separate.
|
||
let big = false, large = false;
|
||
const growBtn = $('grow'), sizeBtn = $('size');
|
||
|
||
// What the open shortcuts list costs, including the margin that separates it from the
|
||
// transcript. Zero while shut. Measured rather than assumed: it is a grid that reflows to one
|
||
// column on a narrow card, so its height is a function of the width it was given.
|
||
function keysCost() {
|
||
const w = $('keys'), b = $('keys-b');
|
||
if (!w || !b || !w.classList.contains('open')) return 0;
|
||
const m = parseFloat(getComputedStyle(b).marginTop) || 0;
|
||
return b.offsetHeight + m;
|
||
}
|
||
|
||
// Returns the height it just applied, in pixels, or null when that is not a number this side
|
||
// can know — a placement sized in vh resolves only against the viewport, and one that has not
|
||
// been given heights yet has nothing to report.
|
||
//
|
||
// It reports rather than letting the caller measure because .vv-ai-chat transitions its height
|
||
// (see the rule in the stylesheet). offsetHeight read straight after this returns the height
|
||
// the box is animating FROM, not the one just asked for, and a page that sizes a sibling from
|
||
// that figure sizes it for a dock that no longer exists by the time anyone looks. On the
|
||
// Scheduler that left the panel's bottom third dead: the view was cut short for a tall dock,
|
||
// the dock then shrank to its collapsed share, and nothing re-measured — so the gap survived
|
||
// until an expand/collapse fired onResize and ran the fit again against a settled box.
|
||
function applyHeights() {
|
||
const el = chatEl();
|
||
const base = el.dataset.h || '';
|
||
const med = el.dataset.hTall || '';
|
||
const big2 = el.dataset.hLarge || med;
|
||
if (!base || !med) return null;
|
||
const want = big ? (large ? big2 : med) : base;
|
||
// Subtracted, not added to. The height the page asked for is the height the whole window
|
||
// keeps; the list is taken out of the conversation's share of it. calc() because that height
|
||
// may be a viewport share while this cost is only ever pixels.
|
||
//
|
||
// Floored, because on the smallest placements the list is taller than the transcript it is
|
||
// being taken from — the Scheduler collapsed at a tenth of its panel is a couple of rows.
|
||
// Leaving a transcript of zero would be worse than briefly outgrowing the budget.
|
||
const k = keysCost();
|
||
el.style.height = k ? 'max(56px, calc(' + want + ' - ' + k + 'px))' : want;
|
||
|
||
if (growBtn) {
|
||
growBtn.textContent = big ? '⤡' : '⤢';
|
||
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
|
||
growBtn.classList.toggle('vv-ai-grow-on', big);
|
||
}
|
||
if (sizeBtn) {
|
||
// Nothing to choose between while collapsed — it would name a size that is not on screen
|
||
// and does not become so until the button beside it is pressed.
|
||
sizeBtn.style.display = big ? '' : 'none';
|
||
// Names the size in force, not the one it would switch to. A button labelled with its own
|
||
// effect reads as a statement of current state to about half of everyone who sees it, and
|
||
// the two readings disagree about what clicking does — so it states, and the title says
|
||
// what happens.
|
||
sizeBtn.textContent = large ? 'Large' : 'Medium';
|
||
sizeBtn.title = large ? 'Expanded is large — click for medium'
|
||
: 'Expanded is medium — click for large';
|
||
sizeBtn.classList.toggle('vv-ai-grow-on', large);
|
||
}
|
||
|
||
// Only a plain pixel figure can be resolved here; anything else is left to the caller to
|
||
// measure, which is correct for the placements whose height is not a transitioning number.
|
||
const n = parseFloat(want);
|
||
if (!Number.isFinite(n) || !/^\s*[\d.]+px\s*$/.test(want)) return null;
|
||
return k ? Math.max(56, n - k) : n;
|
||
}
|
||
// Named functions, because the buttons are no longer the only way in — the shortcuts below
|
||
// drive the same two.
|
||
//
|
||
// onResize fires from these and never from setHeights(). A page whose layout depends on this
|
||
// height calls setHeights() from inside its fit routine, so announcing it there would call
|
||
// that routine from within itself, forever.
|
||
//
|
||
// A placement that is not simply taller than its neighbours has to be told. The Scheduler
|
||
// panel is pinned at the bottom and the views above it are sized from what is left, so without
|
||
// this the chat grows downward off the end of the panel and takes its own composer with it —
|
||
// which is precisely what it did.
|
||
// Announced twice: now, so a page that lays out around this tracks the change as it happens,
|
||
// and again once it has finished.
|
||
//
|
||
// The transcript animates its height — see the transition on .vv-ai-chat — so a page that
|
||
// measures the moment we change it reads a box that is still moving. The Scheduler sizes the
|
||
// view above from the panel's measured height, and re-measuring one frame after a collapse
|
||
// read a dock caught part of the way down: the view came out short by roughly the height the
|
||
// chat was shrinking from, leaving a dead strip under the content that nothing filled. It only
|
||
// ever showed on collapse, because on expand the same error sizes the view too large and flex
|
||
// quietly shrinks it back.
|
||
let settle = null;
|
||
function announceResize() {
|
||
onResize(big);
|
||
clearTimeout(settle);
|
||
// Comfortably past the 140ms transition. A timer rather than transitionend, which does not
|
||
// fire at all when the computed height happens not to change.
|
||
settle = setTimeout(() => onResize(big), 220);
|
||
}
|
||
|
||
function toggleBig() { big = !big; applyHeights(); scroll(); announceResize(); }
|
||
// Only meaningful while expanded, and the button is only reachable then. The shortcut has to
|
||
// check for itself rather than silently changing a size nobody can see.
|
||
function toggleSize() {
|
||
if (!big) return;
|
||
large = !large; applyHeights(); scroll(); announceResize();
|
||
}
|
||
|
||
if (growBtn) growBtn.addEventListener('click', toggleBig);
|
||
if (sizeBtn) sizeBtn.addEventListener('click', toggleSize);
|
||
|
||
// ── Shortcuts panel ──────────────────────────────────────────────────
|
||
// One key for every instance, not one each. The list is identical everywhere, so remembering
|
||
// it per surface would mean closing the same panel three times to be rid of it.
|
||
//
|
||
// Absent means never seen, which is the only time it opens by itself.
|
||
const KEYS_OPEN = 'vv-ai-keys-open';
|
||
const keysWrap = $('keys'), keysT = $('keys-t');
|
||
if (keysWrap && keysT) {
|
||
// applyHeights() after the class, never before: the cost is measured off the rendered list,
|
||
// and while it is still display:none that measurement is zero.
|
||
const applyKeys = open => {
|
||
keysWrap.classList.toggle('open', open);
|
||
keysT.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
keysT.classList.toggle('vv-ai-grow-on', open);
|
||
applyHeights();
|
||
};
|
||
applyKeys(localStorage.getItem(KEYS_OPEN) !== '0');
|
||
keysT.addEventListener('click', () => {
|
||
const open = !keysWrap.classList.contains('open');
|
||
localStorage.setItem(KEYS_OPEN, open ? '1' : '0');
|
||
applyKeys(open);
|
||
scroll();
|
||
// The total is meant to hold, but the floor above can break that on a small placement, so
|
||
// the page is told either way rather than being left to find out by clipping. Through the
|
||
// same settle as the size controls: the list appears at once but the transcript animates
|
||
// to its new height, so an immediate measurement is of a box still moving.
|
||
announceResize();
|
||
});
|
||
}
|
||
|
||
// Bound to the component, not to document. Alt+N and friends are browser menu accelerators;
|
||
// claiming them page-wide would break the tab this lives on. Inside the chat they are
|
||
// unambiguous, and everything they do is visible from where the operator already is.
|
||
const wrapEl = chatEl().closest('.vv-ai-chatwrap');
|
||
if (wrapEl) {
|
||
wrapEl.addEventListener('keydown', e => {
|
||
const inp = $('input');
|
||
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); return; }
|
||
|
||
// Terminal habit: an empty box and Up gets back what you just asked, to edit rather than
|
||
// retype. Only when empty, or it would eat cursor movement in a question being written.
|
||
if (e.key === 'ArrowUp' && e.target === inp && inp.value === '') {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].role === 'user') {
|
||
e.preventDefault();
|
||
inp.value = messages[i].content;
|
||
inp.setSelectionRange(inp.value.length, inp.value.length);
|
||
// Setting .value in script fires no input event, so the draft would still hold
|
||
// whatever was there before the recall.
|
||
saveDraft();
|
||
break;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
const wasOpen = (picker && picker.classList.contains('open'))
|
||
|| (histWrap && histWrap.classList.contains('open'));
|
||
closeMenus();
|
||
// Only clears once there is no menu left to close, so one Escape never does two things.
|
||
// clearDraft too, or Escape would empty the box and the text would reappear on the next
|
||
// tab swap — an Escape that does not actually discard anything.
|
||
if (!wasOpen && e.target === inp && inp.value !== '') {
|
||
e.preventDefault(); inp.value = ''; clearDraft();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!e.altKey || e.ctrlKey || e.metaKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'n') { e.preventDefault(); newChat(); inp.focus(); }
|
||
else if (k === 'e') { e.preventDefault(); toggleBig(); }
|
||
else if (k === 's') { e.preventDefault(); toggleSize(); }
|
||
else if (k === 'h') { const hb = $('hist-b'); if (hb) { e.preventDefault(); hb.click(); } }
|
||
});
|
||
}
|
||
|
||
// Surface any script error into the transcript. Without it a throw anywhere on the page is
|
||
// invisible unless the console happens to be open, which is how a silent hang survives a
|
||
// diagnosis session.
|
||
const onErr = e => {
|
||
if (!busy) return;
|
||
addError('Script error: ' + (e.message || 'unknown')
|
||
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
|
||
finish();
|
||
};
|
||
const onRej = e => {
|
||
if (!busy) return;
|
||
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
|
||
finish();
|
||
};
|
||
window.addEventListener('error', onErr);
|
||
window.addEventListener('unhandledrejection', onRej);
|
||
|
||
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.
|
||
// The checkbox decides, and it defaults ON — an unset key means a first visit, not a refusal.
|
||
// Read here rather than from the element, because construction runs before the header is
|
||
// wired and an unchecked box must prevent the fetch, not undo it afterwards.
|
||
let _resumeWanted = true;
|
||
try { _resumeWanted = localStorage.getItem(RESUME_KEY) !== '0'; } catch (_) {}
|
||
const resumeEl = $('resume');
|
||
if (resumeEl) {
|
||
resumeEl.checked = _resumeWanted;
|
||
resumeEl.addEventListener('change', () => {
|
||
try { localStorage.setItem(RESUME_KEY, resumeEl.checked ? '1' : '0'); } catch (_) {}
|
||
});
|
||
}
|
||
|
||
if (store && o.resume !== false && _resumeWanted) {
|
||
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;
|
||
|
||
// A placement whose profile never moves should not resume a thread from one that does.
|
||
// The store is shared on purpose — a conversation started on the dashboard is the one you
|
||
// carry on in the tab — but reopening restores the saved profile too, so the Monitor
|
||
// card would quietly turn into whatever the Scheduler was last troubleshooting. Filtered
|
||
// where the page says its profile is fixed; unfiltered on the AI tab, where moving
|
||
// between profiles is the point and the newest thread is genuinely the one you left.
|
||
const want = o.resumeProfile || '';
|
||
const pool = want ? d.chats.filter(c => (c.profile || 'chat') === want) : d.chats;
|
||
if (!pool.length) return;
|
||
|
||
const last = pool.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, offer,
|
||
busy: () => busy,
|
||
expanded: () => big,
|
||
currentId: () => chatId,
|
||
|
||
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
|
||
// than a constant. Re-applies immediately at whichever of the two states is current, so a
|
||
// resize while expanded stays expanded instead of snapping back.
|
||
// Hands back the pixel height it settled on, so a caller sizing a sibling from it does not
|
||
// have to measure a box that is mid-transition. See applyHeights().
|
||
setHeights(base, tallMed, tallLarge) {
|
||
const el = chatEl();
|
||
el.dataset.h = base; el.dataset.hTall = tallMed;
|
||
if (tallLarge) el.dataset.hLarge = tallLarge;
|
||
return applyHeights();
|
||
},
|
||
|
||
// Same contract, different subject — the Scheduler pointing the chat at another script, log
|
||
// or conf as the operator moves around the tab. Distinct from setProfile: that changes who
|
||
// is answering, this changes what about.
|
||
//
|
||
// The transcript keeps everything and only the model's floor moves, because a troubleshooting
|
||
// thread about one script must not bleed into a question about another, while hiding that the
|
||
// earlier exchange happened is worse than carrying it visibly. chatId is dropped with it: the
|
||
// scope is part of the stored record, so appending turns about a different thing to the same
|
||
// row would produce a conversation whose stored scope describes only its first half.
|
||
retarget(prof, label, note) {
|
||
if (label !== undefined) scopeLabel = label;
|
||
applyProfile(PROFILES[prof] ? prof : profile);
|
||
if (messages.length > sendFrom && note) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(note) + '</div>'));
|
||
scroll();
|
||
}
|
||
sendFrom = messages.length;
|
||
chatId = '';
|
||
lastSources = [];
|
||
},
|
||
|
||
// A line in the transcript that is not a turn — something the page did, said where the
|
||
// operator is already looking rather than in a banner they have to notice.
|
||
note: noteLine,
|
||
teardown() {
|
||
clearInterval(pendingTimer);
|
||
// Would otherwise fire a re-layout at a page whose instance no longer exists.
|
||
clearTimeout(settle);
|
||
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];
|
||
},
|
||
};
|
||
window.__vvAiChat[P] = inst;
|
||
return inst;
|
||
};
|
||
|
||
// ── Stored conversations list ───────────────────────────────────────────
|
||
// Rendered wherever it is wanted and pointed at a chat instance. Both surfaces show the same
|
||
// rows off the same store, so opening a thread on Monitor and finishing it on the AI tab is
|
||
// one conversation rather than two.
|
||
window.VvAiChatList = function (o) {
|
||
const box = document.getElementById(o.into);
|
||
if (!box) return null;
|
||
let rows = [], activeId = '';
|
||
|
||
// Optional first section: this card's own profile, collapsed, holding its most recent
|
||
// conversations. Underneath it the full list carries on unchanged.
|
||
//
|
||
// The two answer different questions. "Where was I in General Chat" is the one asked on a
|
||
// card pinned to General Chat, and a single flat list buries it under whatever the tabs have
|
||
// been doing — the profile with the most threads wins the top of the list regardless of which
|
||
// card you are looking at. Collapsed by default because the card resumes the newest of these
|
||
// anyway; opening it is for reaching the other nine.
|
||
const GROUPED = o.grouped !== false; // one collapsible section per profile
|
||
const GROUP_MAX = o.groupMax || 10; // per profile, and for the recent list below
|
||
const OPEN_KEY = 'vvAiListOpen:' + (o.into || '');
|
||
|
||
// Which sections are open, by profile key. Remembered as a set rather than one flag: the
|
||
// point of per-profile sections is that they are asked about independently.
|
||
let openSet = {};
|
||
try { openSet = JSON.parse(localStorage.getItem(OPEN_KEY) || '{}') || {}; } catch (_) { openSet = {}; }
|
||
// The card's own profile starts open — it is the one its operator is most likely to want,
|
||
// and on a card pinned to a profile an all-collapsed list says nothing on arrival.
|
||
if (o.groupProfile && !(o.groupProfile in openSet)) openSet[o.groupProfile] = true;
|
||
|
||
function rowHtml(c, P) {
|
||
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
|
||
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
||
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
|
||
+ `<span class="vv-ai-crow-t" title="${esc((tag ? tag + ' — ' : '') + c.title
|
||
+ ' · ' + chatAges(c))}">`
|
||
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
|
||
+ `${esc(c.title)}</span>`
|
||
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
|
||
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
|
||
+ `</div>`;
|
||
}
|
||
|
||
// One collapsible section per profile, each holding that profile's most recent GROUP_MAX.
|
||
//
|
||
// Profile order comes from the registry, not from the data, so the sections stay in the same
|
||
// places as threads come and go — a list that reorders itself under the cursor is a list you
|
||
// have to re-read every time. Profiles with nothing in them are still shown, with a count of
|
||
// zero: their absence would otherwise read as a bug on the day you first look for one.
|
||
function groupHtml() {
|
||
if (!GROUPED) return '';
|
||
const P = window.VvAiProfiles || {};
|
||
const keys = Object.keys(P);
|
||
if (!keys.length) return '';
|
||
|
||
const byProf = {};
|
||
for (const c of rows) (byProf[c.profile || 'chat'] = byProf[c.profile || 'chat'] || []).push(c);
|
||
|
||
let html = '';
|
||
for (const k of keys) {
|
||
const name = (P[k] && P[k].short) || k;
|
||
const mine = (byProf[k] || [])
|
||
.slice().sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
||
.slice(0, GROUP_MAX);
|
||
const open = !!openSet[k];
|
||
html += `<div class="vv-ai-cgroup">`
|
||
+ `<div class="vv-ai-chead" data-grp="${esc(k)}" style="display:flex;align-items:center;
|
||
gap:6px;cursor:pointer;padding:3px 4px;user-select:none;">`
|
||
+ `<span style="color:#666;font-size:10px;width:8px;">${open ? '▾' : '▸'}</span>`
|
||
+ `<span style="color:#888;font-size:10px;font-weight:600;">${esc(name)}</span>`
|
||
+ `<span style="color:#333;font-size:9px;">${mine.length}</span>`
|
||
+ `</div>`
|
||
+ (open
|
||
? `<div class="vv-ai-clist">`
|
||
+ (mine.length ? mine.map(c => rowHtml(c, P)).join('')
|
||
: '<div class="vv-ai-none">none yet</div>')
|
||
+ `</div>`
|
||
: '')
|
||
+ `</div>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// The banner is a heading, not decoration: without it the rows below read as a sixth profile
|
||
// section rather than as the cross-profile recents.
|
||
function recentBanner(n) {
|
||
return `<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">`
|
||
+ `<div style="flex:1;border-bottom:1px solid #1e1e1e;"></div>`
|
||
+ `<span style="color:#4a4a4a;font-size:9px;text-transform:uppercase;
|
||
letter-spacing:.06em;white-space:nowrap;">Last ${n} conversations</span>`
|
||
+ `<div style="flex:1;border-bottom:1px solid #1e1e1e;"></div>`
|
||
+ `</div>`;
|
||
}
|
||
|
||
function render() {
|
||
if (!rows.length) {
|
||
// No sections on a genuinely empty store — five headings all reading 0 is a shape that
|
||
// implies something is filtered out rather than that nothing has been said yet.
|
||
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
|
||
return;
|
||
}
|
||
// The scope is shown, not just stored. A Scheduler thread is about one script or log,
|
||
// and a list of titles alone makes "why does this one talk about a log I never opened"
|
||
// an unanswerable question.
|
||
// Tagged only when the tag says something. A scope always does; a profile does unless it
|
||
// is the ordinary one — labelling every General Chat row "Chat" is a column of noise that
|
||
// makes the rows that are genuinely different harder to pick out, which is the opposite
|
||
// of the point.
|
||
const P = window.VvAiProfiles || {};
|
||
// Capped to the same GROUP_MAX. Uncapped, this repeated most of what the sections above
|
||
// already show and pushed them off the top of a card that is only 300px tall.
|
||
const recent = rows.slice()
|
||
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
||
.slice(0, GROUP_MAX);
|
||
box.innerHTML = groupHtml()
|
||
+ (GROUPED ? recentBanner(recent.length) : '')
|
||
+ '<div class="vv-ai-clist">' + recent.map(c => rowHtml(c, P)).join('') + '</div>';
|
||
}
|
||
|
||
function load() {
|
||
fetch(API + '?action=chats').then(r => r.json())
|
||
.then(d => { if (d.ok) { rows = d.chats || []; render(); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
box.addEventListener('click', e => {
|
||
// Before the row handler: the header sits above the rows and must not be read as one.
|
||
const grp = e.target.closest('[data-grp]');
|
||
if (grp) {
|
||
const k = grp.dataset.grp;
|
||
openSet[k] = !openSet[k];
|
||
try { localStorage.setItem(OPEN_KEY, JSON.stringify(openSet)); } catch (_) {}
|
||
render();
|
||
return;
|
||
}
|
||
const del = e.target.closest('[data-del]');
|
||
if (del) {
|
||
e.stopPropagation();
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'chat_delete', id: del.dataset.del }) })
|
||
.then(() => {
|
||
// Deleting the conversation you are in leaves the transcript on screen but detached
|
||
// from the store, which would silently resurrect it on the next turn.
|
||
if (del.dataset.del === activeId && o.chat) o.chat.newChat();
|
||
load();
|
||
}).catch(() => {});
|
||
return;
|
||
}
|
||
const row = e.target.closest('.vv-ai-crow');
|
||
if (row && o.chat) { activeId = row.dataset.id; render(); o.chat.loadChat(row.dataset.id); }
|
||
});
|
||
|
||
return {
|
||
reload: load,
|
||
setActive(id) { activeId = id; load(); },
|
||
};
|
||
};
|
||
})();
|
||
</script>
|
||
<?php
|
||
}
|
||
|
||
// The shortcuts, in one place. Rendered into the panel by the markup below and acted on by the
|
||
// keydown handler in the script above, so a key that stops working cannot keep being advertised.
|
||
//
|
||
// Everything here fires only while focus is inside the chat. Alt combinations are browser menu
|
||
// accelerators when nothing has focus, and a chat that stole them page-wide would be a chat that
|
||
// broke the tab it lives on.
|
||
function vv_ai_chat_keys(): array {
|
||
return [
|
||
['keys' => ['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'],
|
||
['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'],
|
||
['keys' => ['/'], 'what' => 'Command — /help lists them', 'id' => 'slash'],
|
||
['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'],
|
||
['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'],
|
||
['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],
|
||
['keys' => ['Alt', 'S'], 'what' => 'Medium or large', 'id' => 'size'],
|
||
['keys' => ['Alt', 'H'], 'what' => 'Saved conversations', 'id' => 'hist'],
|
||
];
|
||
}
|
||
|
||
// One instance's markup. The prefix composes every id, so a page may render more than 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, the collapsed state. 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 surface that forgot to pass a number.
|
||
// tall expanded at the Medium setting. Defaults to 2x height.
|
||
// tallLarge expanded at the Large setting. Defaults to 3x height.
|
||
// Two expanded sizes because ⤢ and the banner's Medium/Large answer different
|
||
// questions: whether the conversation is expanded, and how much that is worth. The
|
||
// first is an action taken constantly, the second a preference set once.
|
||
// title banner text. Defaults to "Assistant"
|
||
// icon raw SVG for the banner, page-supplied and emitted unescaped
|
||
// empty empty-state text
|
||
// 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']);
|
||
// 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.';
|
||
|
||
// Derived from the resting height, so a page need only state the one number it actually cares
|
||
// about. Large is 3x, capped at 85vh — vh is a share of the viewport and an expand that puts
|
||
// the composer out of reach is worse than no expand.
|
||
//
|
||
// Medium is placed between resting and large rather than at a multiple of its own, because a
|
||
// fixed multiple collapses under that cap: at 52vh resting, 2x and 3x are both 85vh after
|
||
// clamping, and the Medium/Large button would have had two settings that did the same thing.
|
||
$sizes = function (string $from): array {
|
||
if (!preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $from, $m)) return ['', ''];
|
||
$fmt = fn(float $n) => rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $m[2];
|
||
$base = (float)$m[1];
|
||
$lg = $base * 3.0;
|
||
if ($m[2] === 'vh') $lg = min($lg, 85.0);
|
||
return [$fmt($base + ($lg - $base) * 0.55), $fmt($lg)];
|
||
};
|
||
[$defMed, $defLarge] = $sizes($height);
|
||
$tall = $o['tall'] ?? $defMed;
|
||
$large = $o['tallLarge'] ?? $defLarge;
|
||
|
||
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
|
||
. ';max-height:none;"'
|
||
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
|
||
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"'
|
||
. ' data-h-large="' . htmlspecialchars($large, ENT_QUOTES) . '"' : '';
|
||
?>
|
||
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
|
||
|
||
<!-- Banner. Names the thing, and carries the setting rather than the action: how big expanded
|
||
is, not whether it is expanded. ⤢ below does the expanding, which is a thing you do to the
|
||
conversation and belongs with the other things you do to it. This picks what that action
|
||
will be worth, which you set once and rarely revisit.
|
||
Hidden while collapsed, because at that point it describes nothing on screen. -->
|
||
<div class="vv-ai-head">
|
||
<span class="vv-ai-head-t">
|
||
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
|
||
<span id="<?= $p ?>-title"><?= htmlspecialchars($o['title'] ?? 'Assistant') ?></span>
|
||
<?php if (!empty($o['mesh'])): ?>
|
||
<!-- The badge is the whole reason a toggle is acceptable here. Behind a switch, an arriving
|
||
message is invisible until somebody happens to flip it — and the messages this carries
|
||
are the ones you most need to not miss ("mine is going down for a week"). Purple because
|
||
nothing else on these pages is, so it reads as new rather than as another warning. -->
|
||
<button type="button" id="<?= $p ?>-mode" class="vv-ai-btn ghost"
|
||
onclick="vvNcMode()" title="Switch between the assistant and the mesh"
|
||
style="margin-left:8px;font-size:10px;padding:2px 8px;">
|
||
<span id="<?= $p ?>-mode-l">Mesh Chat</span>
|
||
<span id="vv-nc-badge" hidden
|
||
style="margin-left:5px;background:#7c4dff;color:#fff;border-radius:8px;
|
||
padding:0 6px;font-size:9px;font-weight:700;">✉ 0</span>
|
||
</button>
|
||
<?php endif; ?>
|
||
</span>
|
||
|
||
<!-- Centred by giving the title and the right-hand group equal flex, not by margins — the
|
||
title changes width per placement and the buttons wrap on a narrow panel, and either
|
||
would drag a margin-centred group off centre. Same reasoning as the composer row.
|
||
|
||
Both are view controls: neither changes what is asked, only what you watch while it
|
||
happens. That is why they sit here with the window controls rather than in the composer
|
||
beside the things that shape the question. -->
|
||
<span class="vv-ai-head-c" id="<?= $p ?>-cb-ai">
|
||
<label class="vv-ai-cb" title="Show the model's reasoning as it is written, not after">
|
||
<input type="checkbox" id="<?= $p ?>-see-think"> <span>Reasoning</span>
|
||
</label>
|
||
<!-- Governs the NEXT open, not this one: by the time it can be clicked the thread it would
|
||
have resumed is already on screen. The title says so, because a checkbox that appears
|
||
to do nothing when ticked is worse than no checkbox. -->
|
||
<label class="vv-ai-cb" title="Reopen this card on your last conversation. Applies next time the page loads.">
|
||
<input type="checkbox" id="<?= $p ?>-resume" checked> <span>Load Last</span>
|
||
</label>
|
||
<label class="vv-ai-cb" title="Follow the newest line. Unticks when you scroll up, re-ticks at the bottom.">
|
||
<input type="checkbox" id="<?= $p ?>-follow" checked> <span>Auto Scroll</span>
|
||
</label>
|
||
</span>
|
||
|
||
<?php if (!empty($o['mesh'])): ?>
|
||
<!-- The mesh's three, in the same place and the same three slots. Switching mode swaps this
|
||
group for the one above rather than leaving the assistant's controls sitting over a
|
||
conversation they do not govern — which is what a second row inside the pane amounted to,
|
||
and why the banner looked unchanged after switching. -->
|
||
<span class="vv-ai-head-c" id="<?= $p ?>-cb-mesh" hidden>
|
||
<label class="vv-ai-cb"
|
||
title="Render colours, fonts and sizes as sent. Unticked shows every message as plain text.">
|
||
<input type="checkbox" id="vv-nc-fmt-on" checked onchange="vvNcPref();vvNcRender()"> <span>Formatting</span>
|
||
</label>
|
||
<label class="vv-ai-cb"
|
||
title="Show only what has arrived since you last looked. Unticked shows the whole conversation.">
|
||
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Only New</span>
|
||
</label>
|
||
<label class="vv-ai-cb"
|
||
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
|
||
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref()"> <span>Auto Scroll</span>
|
||
</label>
|
||
</span>
|
||
<?php endif; ?>
|
||
|
||
<span class="vv-ai-head-r">
|
||
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
|
||
style="display:none">Medium</button>
|
||
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
|
||
it, which has nothing to say while collapsed. -->
|
||
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
|
||
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
|
||
most people means it is shut a minute later and never thought about again — which is the
|
||
point. A panel that is closed by default is one nobody discovers; one that cannot be closed
|
||
is clutter on every load forever.
|
||
|
||
It is paid for out of the transcript rather than added underneath the banner: the window
|
||
keeps the height it was given and the conversation gives up the rows, so opening this never
|
||
pushes the composer down or, on the Scheduler, past the end of the panel. Closing hands the
|
||
rows straight back. See applyHeights().
|
||
|
||
The list is data, so the keys shown and the keys handled come from one place and cannot
|
||
drift into describing a shortcut that no longer works. -->
|
||
<div class="vv-ai-keys" id="<?= $p ?>-keys">
|
||
<div class="vv-ai-keys-b" id="<?= $p ?>-keys-b">
|
||
<?php foreach (vv_ai_chat_keys() as $k): ?>
|
||
<div class="vv-ai-key">
|
||
<span class="vv-ai-key-k"><?php foreach ($k['keys'] as $cap): ?><kbd><?= htmlspecialchars($cap) ?></kbd><?php endforeach; ?></span>
|
||
<span class="vv-ai-key-d"><?= htmlspecialchars($k['what']) ?></span>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Positioned wrapper so the jump pill can sit over the transcript's bottom edge without
|
||
being clipped by its overflow, and without taking a row of its own when hidden. -->
|
||
<!-- Two panes, one card. The mesh pane is a sibling of this one rather than a second card, so
|
||
switching what the card is does not give the page a second header to look at. -->
|
||
<div id="<?= $p ?>-pane-ai">
|
||
<div class="vv-ai-chat-wrap">
|
||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
||
</div>
|
||
<button type="button" class="vv-ai-jump" id="<?= $p ?>-jump" hidden>↓ newest</button>
|
||
</div>
|
||
|
||
<div class="vv-ai-composer">
|
||
<!-- Acts on the last exchange only, and is hidden until there is one. Redoing the previous turn
|
||
is nearly the whole of what conversation control means in practice, and a row of buttons
|
||
under every message would spend a line each to serve the rare case. -->
|
||
<div class="vv-ai-last" id="<?= $p ?>-last" hidden>
|
||
<button type="button" class="vv-ai-lnk" data-last="retry">Retry</button>
|
||
<button type="button" class="vv-ai-lnk" data-last="edit">Edit question</button>
|
||
<button type="button" class="vv-ai-lnk" data-last="drop">Delete exchange</button>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<!-- Three groups, and everything in all of them is clickable. What is answering holds the
|
||
left; what you do to the conversation sits in the middle, where the composer above it
|
||
is; what you do to the window holds the right. Grouping by what a control acts on rather
|
||
than by importance means the middle is the only part you look at while working.
|
||
|
||
Centred by giving the two outer groups equal flex, not by margins — the middle stays put
|
||
when the chip's text changes length, which on the Scheduler it does on every view switch.
|
||
|
||
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-side" 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>
|
||
</div>
|
||
|
||
<div class="vv-ai-actions">
|
||
<?= $o['controls'] ?? '' ?>
|
||
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
|
||
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
|
||
from either is the same thread. Worded, not a chevron: a bare ▾ four buttons away from
|
||
the chip's ▾ was two dropdowns wearing one glyph for unrelated jobs. -->
|
||
<div class="vv-ai-picker" id="<?= $p ?>-hist">
|
||
<button class="vv-ai-btn ghost" id="<?= $p ?>-hist-b" type="button"
|
||
aria-haspopup="listbox" aria-expanded="false"
|
||
title="Reopen a saved conversation">Saved</button>
|
||
<div class="vv-ai-menu vv-ai-menu-r" id="<?= $p ?>-hist-menu" role="listbox"></div>
|
||
</div>
|
||
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
|
||
</div>
|
||
|
||
<div class="vv-ai-side vv-ai-tail">
|
||
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
|
||
title="Give the conversation more room">⤢</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><?php /* close -pane-ai */ ?>
|
||
<?php if (!empty($o['mesh']) && function_exists('vv_nc_pane_markup')) vv_nc_pane_markup($p, !empty($o['meshDefault']), $height, $tall); ?>
|
||
</div>
|
||
<?php
|
||
}
|
||
|
||
// The stored-conversations container. Rendered separately from the chat because the two live in
|
||
// different cards on Monitor and in different parts of the column on the AI tab.
|
||
//
|
||
// Takes compact for the same reason the chat does: these rows are painted against whatever they
|
||
// sit on, and a card and a panel are not the same ground.
|
||
function vv_ai_chat_list_markup(string $prefix, bool $compact = false): void {
|
||
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
||
?>
|
||
<div id="<?= $p ?>-chats"<?= $compact ? ' class="vv-ai-c"' : '' ?>><div class="vv-ai-none">loading…</div></div>
|
||
<?php
|
||
}
|