867 lines
45 KiB
PHP
867 lines
45 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The conversation surface itself — profile bar, transcript, composer, source viewer and the
|
||
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the
|
||
// Monitor tab's AI row.
|
||
//
|
||
// 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 tab's dock is deliberately NOT built on this. It is a one-line bar that follows
|
||
// the view you have open, with its own scope chip, fix flow and incident capture; it is a
|
||
// different component that happens to talk to the same endpoint. Folding it in here would mean
|
||
// one widget with two personalities and a mode flag deciding which.
|
||
//
|
||
// 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';
|
||
|
||
// Emitted once even if two instances are rendered. A second copy of the script would re-register
|
||
// the factories harmlessly but would also install a second Escape handler and a second copy of
|
||
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
|
||
// The conversation store, on its own, with no styling and no chat widget attached.
|
||
//
|
||
// Emitted separately because the surface that most needs it is the one that does not want the
|
||
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and
|
||
// pulling in the full transcript stylesheet to reach a save function would restyle a component
|
||
// that was deliberately built to look different.
|
||
//
|
||
// 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
|
||
}
|
||
|
||
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; }
|
||
|
||
/* ── Profiles ───────────────────────────────────────────────────────────── */
|
||
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
|
||
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
|
||
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
|
||
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
|
||
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
|
||
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
|
||
border-top:1px dashed #1e1e1e; padding-top:8px; }
|
||
|
||
/* ── Transcript ─────────────────────────────────────────────────────────── */
|
||
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
|
||
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||
.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; }
|
||
.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; }
|
||
.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; }
|
||
.vv-ai-think.open { display:block; }
|
||
|
||
.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; }
|
||
.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; }
|
||
|
||
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
|
||
.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; }
|
||
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||
padding:4px 7px; border-radius:3px; }
|
||
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
|
||
|
||
/* ── 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; }
|
||
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; background:#2a2a2a;
|
||
border-color:#555; color:#ccc; }
|
||
.vv-ai-c .vv-ai-prof:hover { border-color:#888; color:#fff; }
|
||
.vv-ai-c .vv-ai-prof.active { background:#152238; border-color:#4a7ab0; color:#9bd; }
|
||
.vv-ai-c .vv-ai-prof-hint { display:none; }
|
||
.vv-ai-c .vv-ai-hint { display:none; }
|
||
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
|
||
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
|
||
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
|
||
.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-grow { font-size:13px !important; line-height:1; padding:4px 9px !important; }
|
||
.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 dock 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';
|
||
}
|
||
|
||
// ── 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 () {};
|
||
const store = o.chats !== false;
|
||
const POLL_MS = 1200;
|
||
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 busy = false;
|
||
let lastSources = [];
|
||
let pendingTimer = null;
|
||
|
||
const chatEl = () => $('chat');
|
||
const scroll = () => { 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(); }
|
||
|
||
// ── Minimal markdown, applied strictly after escaping ────────────────
|
||
function fmt(text) {
|
||
let h = esc(text);
|
||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
|
||
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></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; }
|
||
|
||
// 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 '';
|
||
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
|
||
sources.forEach((s, i) => {
|
||
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>';
|
||
}
|
||
|
||
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">`
|
||
+ `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>`;
|
||
}
|
||
chatEl().appendChild(el(h + '</div>')); scroll();
|
||
}
|
||
|
||
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 => {
|
||
const think = e.target.closest('.vv-ai-think-t');
|
||
if (think) { think.nextElementSibling.classList.toggle('open'); return; }
|
||
const src = e.target.closest('.vv-ai-src-i');
|
||
if (src && src.dataset.src) { 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.path) vvAiOpen(s.path);
|
||
}
|
||
});
|
||
|
||
// ── 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;
|
||
|
||
busy = true;
|
||
$('send').disabled = true;
|
||
addUser(q);
|
||
$('input').value = '';
|
||
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;
|
||
|
||
let res;
|
||
try {
|
||
const body = new URLSearchParams({
|
||
action: 'ask',
|
||
profile: profile,
|
||
question: q,
|
||
history: JSON.stringify(sendable()),
|
||
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
|
||
think: (thinkEl ? thinkEl.checked : true) ? '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 });
|
||
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);
|
||
return messages.slice(floor);
|
||
}
|
||
|
||
function finish() {
|
||
busy = false;
|
||
const b = $('send'); if (b) b.disabled = false;
|
||
clearInterval(pendingTimer);
|
||
}
|
||
|
||
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 || {};
|
||
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();
|
||
onTurn();
|
||
return;
|
||
}
|
||
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||
phase(j.status === 'generating'
|
||
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
|
||
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||
setTimeout(() => poll(token, started), 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 => {
|
||
if (m.role === 'user') { addUser(m.content); return; }
|
||
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body">${fmt(m.content)}</div></div>`));
|
||
});
|
||
scroll();
|
||
}
|
||
|
||
function reset() {
|
||
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
|
||
}
|
||
|
||
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 bar = $('profiles');
|
||
if (bar) bar.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.prof === p));
|
||
const hint = $('prof-hint');
|
||
if (hint) hint.textContent = PROFILES[p].hint;
|
||
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||
if (kindEl) kindEl.style.display = PROFILES[p].kind ? '' : 'none';
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
const bar = $('profiles');
|
||
if (bar) bar.addEventListener('click', e => {
|
||
const b = e.target.closest('.vv-ai-prof');
|
||
if (b) setProfile(b.dataset.prof);
|
||
});
|
||
|
||
// ── Wiring ───────────────────────────────────────────────────────────
|
||
$('send').addEventListener('click', send);
|
||
$('input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
|
||
});
|
||
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. Remembered per instance: the dashboard chat and the tab's are different
|
||
// sizes for different reasons, and a shared key would make one of them wrong.
|
||
//
|
||
// 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.
|
||
const growBtn = $('grow');
|
||
if (growBtn) {
|
||
const el = chatEl();
|
||
const base = el.dataset.h || '';
|
||
const tall = el.dataset.hTall || '';
|
||
const KEY = 'vv-ai-tall-' + P;
|
||
const apply = big => {
|
||
if (!base || !tall) return;
|
||
el.style.height = big ? tall : base;
|
||
growBtn.textContent = big ? '⤡' : '⤢';
|
||
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
|
||
growBtn.classList.toggle('vv-ai-grow-on', big);
|
||
scroll();
|
||
};
|
||
growBtn.addEventListener('click', () => {
|
||
const big = el.style.height !== tall;
|
||
localStorage.setItem(KEY, big ? '1' : '0');
|
||
apply(big);
|
||
});
|
||
if (localStorage.getItem(KEY) === '1') apply(true);
|
||
}
|
||
|
||
// 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);
|
||
|
||
const inst = {
|
||
prefix: P,
|
||
setProfile, newChat, loadChat, send,
|
||
currentId: () => chatId,
|
||
teardown() {
|
||
clearInterval(pendingTimer);
|
||
window.removeEventListener('error', onErr);
|
||
window.removeEventListener('unhandledrejection', onRej);
|
||
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 = '';
|
||
|
||
function render() {
|
||
if (!rows.length) {
|
||
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
|
||
return;
|
||
}
|
||
// The scope is shown, not just stored. A Scheduler dock 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 || {};
|
||
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c => {
|
||
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 : c.title)}">`
|
||
+ (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>`;
|
||
}).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 => {
|
||
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
|
||
}
|
||
|
||
// One instance's markup. The prefix composes every id, so a page may render more than one.
|
||
// profile which profile button starts active
|
||
// compact card-sized chrome, for a chat living inside a Monitor card
|
||
// height transcript height; a fixed value in compact mode, since a card in a grid row
|
||
// cannot grow with its content without dragging the row's other cards with it.
|
||
// Setting it also adds the expand control — a fixed height is exactly the situation
|
||
// where you sometimes want more room, and there is nothing to expand without one.
|
||
// tall the expanded height. Defaults to 2.5x height, which is the point where a long
|
||
// answer stops needing a scroll for most questions without the card swallowing the
|
||
// page it sits on
|
||
// empty empty-state text
|
||
// controls extra markup dropped into the composer's control strip
|
||
function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
||
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
||
$compact = !empty($o['compact']);
|
||
$height = $o['height'] ?? '';
|
||
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
|
||
// 2.5x by default, computed here so the page can override with a value that suits its layout
|
||
// rather than the include guessing at one it cannot see.
|
||
$tall = $o['tall'] ?? '';
|
||
if ($height !== '' && $tall === '' && preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $height, $hm)) {
|
||
$n = (float)$hm[1] * 2.5;
|
||
// vh is a share of the viewport, so 2.5x runs off the bottom of the screen — an expand
|
||
// that puts the composer out of reach is worse than no expand. Clamped to something that
|
||
// still leaves the page scrollable to its own controls.
|
||
if ($hm[2] === 'vh') $n = min($n, 85);
|
||
$tall = rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $hm[2];
|
||
}
|
||
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
|
||
. ';max-height:none;"'
|
||
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
|
||
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"' : '';
|
||
?>
|
||
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
|
||
|
||
<div class="vv-ai-profiles" id="<?= $p ?>-profiles">
|
||
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
|
||
<button class="vv-ai-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button"
|
||
title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button>
|
||
<?php endforeach; ?>
|
||
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
|
||
</div>
|
||
|
||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
||
</div>
|
||
|
||
<div class="vv-ai-composer">
|
||
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
|
||
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
|
||
<div class="vv-ai-ctrls">
|
||
<?= $o['controls'] ?? '' ?>
|
||
<?php if ($height !== ''): ?>
|
||
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
|
||
title="Give the conversation more room">⤢</button>
|
||
<?php endif; ?>
|
||
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
|
||
<span class="vv-ai-hint">Ctrl+Enter to send</span>
|
||
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
|
||
</div>
|
||
</div>
|
||
</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
|
||
}
|