Move the chat into an include, because the second one was going to be a copy
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
<?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
|
||||
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
|
||||
// api/readscript.php source viewer contents
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 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;
|
||||
?>
|
||||
<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 its
|
||||
own heading and border, so the transcript drops its own and the hint text goes away rather
|
||||
than wrapping to three lines at this width. */
|
||||
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0; }
|
||||
.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; }
|
||||
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; }
|
||||
.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-msg { margin-bottom:12px; }
|
||||
|
||||
/* ── 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-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; }
|
||||
.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>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const API = '/plugins/varaverk/api/ai.php';
|
||||
|
||||
// Server-side is the authority on retrieval depth and history; these are for the UI only, and
|
||||
// they must not drift from VV_AI_PROFILES in api/ai.php.
|
||||
const PROFILES = {
|
||||
varaverk: { label: 'Varaverk Assistant', turns: 3, kind: true,
|
||||
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
|
||||
chat: { label: 'General Chat', turns: 8, kind: false,
|
||||
hint: 'Ordinary conversation. Hands anything about this install to the assistant on its own.' },
|
||||
code: { label: 'Code Sketcher', turns: 4, kind: false,
|
||||
hint: 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.' },
|
||||
};
|
||||
window.VvAiProfiles = PROFILES;
|
||||
|
||||
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,
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c =>
|
||||
`<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
|
||||
+ `<span class="vv-ai-crow-t" title="${esc(c.title)}">${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
|
||||
// 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.';
|
||||
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
|
||||
. ';max-height:none;"' : '';
|
||||
?>
|
||||
<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 (['varaverk' => 'Varaverk Assistant', 'chat' => 'General Chat',
|
||||
'code' => 'Code Sketcher'] as $key => $label): ?>
|
||||
<button class="vv-ai-prof" data-prof="<?= $key ?>" type="button"><?= $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'] ?? '' ?>
|
||||
<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.
|
||||
function vv_ai_chat_list_markup(string $prefix): void {
|
||||
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
||||
?>
|
||||
<div id="<?= $p ?>-chats"><div class="vv-ai-none">loading…</div></div>
|
||||
<?php
|
||||
}
|
||||
Reference in New Issue
Block a user