On the positioned wrapper rather than the scroller — a background on the transcript is anchored to its padding box and drifts as it scrolls.
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/min(78%,460px) no-repeat;
|
||
opacity:.05; 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); ?>
|
||
</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
|
||
}
|