Move the chat into an include, because the second one was going to be a copy
This commit is contained in:
+55
-394
@@ -56,9 +56,11 @@
|
||||
// source viewer overlay
|
||||
//
|
||||
// DEPENDS ON
|
||||
// api/ai.php stats / ask / poll / clear
|
||||
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
|
||||
// api/ai.php stats / tokens / bugs / ask / poll / clear / chats
|
||||
// api/readscript.php source viewer contents
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||||
|
||||
// Build stamp. The tab bar uses Unraid's localURL, which swaps content by AJAX without tearing
|
||||
// down the previous page's JavaScript — so a stale copy of this script can keep running, and
|
||||
@@ -81,56 +83,9 @@ if (is_dir('/var/log/varaverk')) {
|
||||
.vv-ai-stat-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
|
||||
.vv-ai-stat-v { font-size:15px; font-weight:bold; color:#c8c8c8; font-family:monospace; }
|
||||
.vv-ai-stat-s { font-size:10px; color:#5a5a5a; }
|
||||
.vv-ai-ok { color:#6fcf97 !important; }
|
||||
.vv-ai-warn { color:#ffb74d !important; }
|
||||
.vv-ai-bad { color:#e57 !important; }
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* ── Chat ───────────────────────────────────────────────────────────────── */
|
||||
.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; }
|
||||
/* Profiles, transcript, composer and the source overlay are styled by include/ai_chat.php,
|
||||
which the Monitor tab's AI row shares. Only what is unique to this tab lives below. */
|
||||
|
||||
/* ── Health + loaded models ─────────────────────────────────────────────── */
|
||||
/* The split lines the System checks card up with the right edge of the third banner stat.
|
||||
@@ -199,18 +154,6 @@ if (is_dir('/var/log/varaverk')) {
|
||||
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
|
||||
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
|
||||
|
||||
.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-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-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
|
||||
/* ── Settings card ──────────────────────────────────────────────────────── */
|
||||
/* Collapsed by default and by markup, not by JS: the card is closed because the class is
|
||||
simply absent, so it cannot flash open on a slow load or stick open if a script throws.
|
||||
@@ -231,27 +174,8 @@ if (is_dir('/var/log/varaverk')) {
|
||||
.vv-ai-set-r:first-child { border-top:none; }
|
||||
.vv-ai-set-l { font-size:11px; color:#8a8a8a; min-width:120px; flex-shrink:0; }
|
||||
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
|
||||
.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; }
|
||||
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
|
||||
border-radius:3px; cursor:pointer; }
|
||||
.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-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||||
|
||||
/* ── 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>
|
||||
<?php vv_ai_chat_assets(); ?>
|
||||
|
||||
<div id="vv-ai-wrap">
|
||||
|
||||
@@ -295,31 +219,34 @@ if (is_dir('/var/log/varaverk')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-ai-profiles">
|
||||
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
|
||||
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
|
||||
<button class="vv-ai-prof" data-prof="code" type="button">Code Sketcher</button>
|
||||
<span class="vv-ai-prof-hint" id="vv-ai-prof-hint"></span>
|
||||
</div>
|
||||
|
||||
<div class="vv-ai-chat" id="vv-ai-chat">
|
||||
<div class="vv-ai-empty">
|
||||
Ask Varaverk about itself.<br>
|
||||
Answers come only from this installation's own documentation, with sources.
|
||||
<!-- Saved conversations. Above the transcript rather than beside it: this column is already
|
||||
narrow at the width the diag row wants, and a sidebar here would take that width from the
|
||||
thing being read. The same rows render on the Monitor tab off the same store. -->
|
||||
<div class="vv-ai-tok">
|
||||
<div class="vv-ai-diag-col" style="grid-column:1/-1">
|
||||
<div class="vv-ai-chead">
|
||||
<span class="vv-ai-diag-h" style="margin:0">Saved conversations</span>
|
||||
<span class="vv-ai-set-sum" style="margin-left:auto">newest first · oldest drop off automatically</span>
|
||||
</div>
|
||||
<?php vv_ai_chat_list_markup('vv-ai'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-ai-composer">
|
||||
<textarea class="vv-ai-input" id="vv-ai-input" rows="2"
|
||||
placeholder="e.g. what stops rsync and the mover running at once?"></textarea>
|
||||
<div class="vv-ai-ctrls">
|
||||
<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>
|
||||
<button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
|
||||
<span class="vv-ai-hint">Ctrl+Enter to send · 3-turn history · build <?=$_vv_ai_build?>
|
||||
<span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>
|
||||
<button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
vv_ai_chat_markup('vv-ai', [
|
||||
'profile' => 'varaverk',
|
||||
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
|
||||
. 'documentation, with sources.',
|
||||
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
|
||||
// The build stamp and the liveness marker stay on this tab. Unraid swaps tab content by
|
||||
// AJAX without tearing down the previous page's JavaScript, so "is the browser running the
|
||||
// code I just deployed" is not answerable from the server — the marker answers it from the
|
||||
// browser, and it is where every deploy on this tab gets checked.
|
||||
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>'
|
||||
. '<span class="vv-ai-hint">build ' . $_vv_ai_build
|
||||
. ' <span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>',
|
||||
]);
|
||||
?>
|
||||
|
||||
<!-- Settings. Below the composer and closed by default: these change how the next answer is
|
||||
built, not what it is asked, so they should be reachable without being in the way. The
|
||||
@@ -376,42 +303,19 @@ if (is_dir('/var/log/varaverk')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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';
|
||||
const POLL_MS = 1200;
|
||||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||||
const API = '/plugins/varaverk/api/ai.php';
|
||||
|
||||
// Server-side is the authority on retrieval and history depth; these are for the UI only.
|
||||
// Always starts on varaverk — the strict profile is the one you land on, so a misuse costs a
|
||||
// "the docs don't cover that" rather than an invented claim about the system.
|
||||
const PROFILES = {
|
||||
varaverk: { turns: 3, kind: true,
|
||||
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
|
||||
chat: { turns: 8, kind: false,
|
||||
hint: 'Ordinary conversation. Knows your memory notes, but not the docs — it\'ll point you back here for specifics.' },
|
||||
code: { turns: 4, kind: false,
|
||||
hint: 'Drafts short scripts for Custom Scripts. First drafts — it flags flags it isn\'t sure of. Test before trusting.' },
|
||||
};
|
||||
|
||||
let profile = 'varaverk';
|
||||
let history = []; // {role, content} — trimmed per profile
|
||||
let busy = false;
|
||||
let lastSources = [];
|
||||
// The conversation itself — profiles, transcript, composer, source viewer, storage — is
|
||||
// include/ai_chat.php. What remains on this page is everything that surrounds it and exists
|
||||
// only here: the banner, the token ledger, filed bug reports and the memory editor.
|
||||
let chat = null, chatList = null;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s == null ? '' : s)
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
|
||||
// ── Banner ──────────────────────────────────────────────────────────────
|
||||
function stat(label, value, sub, cls) {
|
||||
@@ -714,240 +618,6 @@ if (is_dir('/var/log/varaverk')) {
|
||||
renderTokens();
|
||||
});
|
||||
|
||||
// ── 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" onclick="vvAiCite($1)">[$1]</span>');
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── Transcript ──────────────────────────────────────────────────────────
|
||||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||||
function chat() { return $('vv-ai-chat'); }
|
||||
function scroll() { chat().scrollTop = chat().scrollHeight; }
|
||||
function clearEmpty() { const e = chat().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||||
|
||||
function addUser(text) {
|
||||
clearEmpty();
|
||||
chat().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();
|
||||
}
|
||||
let pendingTimer = null;
|
||||
function addPending() {
|
||||
const n = el(`<div class="vv-ai-msg bot" id="vv-ai-pending"><div class="vv-ai-role">Varaverk</div>`
|
||||
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
|
||||
+ `<span id="vv-ai-phase">starting…</span>`
|
||||
+ `<span id="vv-ai-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
|
||||
chat().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 = $('vv-ai-elapsed');
|
||||
if (!e) { clearInterval(pendingTimer); return; }
|
||||
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
|
||||
}, 1000);
|
||||
}
|
||||
function phase(t) { const p = $('vv-ai-phase'); if (p) p.textContent = t; }
|
||||
|
||||
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" onclick="vvAiOpen('${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 = $('vv-ai-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" onclick="this.nextElementSibling.classList.toggle('open')">`
|
||||
+ `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 the prose warning it may or may not 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>`;
|
||||
}
|
||||
chat().appendChild(el(h + '</div>')); scroll();
|
||||
}
|
||||
|
||||
function addError(msg) {
|
||||
const p = $('vv-ai-pending'); if (p) p.remove();
|
||||
chat().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();
|
||||
}
|
||||
|
||||
// ── 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. That cost a diagnosis
|
||||
// session; it now says so and offers the way out.
|
||||
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 = $('vv-ai-input').value.trim();
|
||||
if (!q) return;
|
||||
|
||||
busy = true;
|
||||
$('vv-ai-send').disabled = true;
|
||||
addUser(q);
|
||||
$('vv-ai-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. A hang is the one failure
|
||||
// that tells you nothing, so every path below has to end in a visible message.
|
||||
// URLSearchParams, not FormData. FormData sends multipart/form-data, and a multipart POST
|
||||
// to this endpoint hangs with no status code 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. Every other POST on this host that demonstrably works, including
|
||||
// Unraid's own, is application/x-www-form-urlencoded. Same fields, same $_POST on the
|
||||
// server; only the encoding changes.
|
||||
let res;
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
action: 'ask',
|
||||
profile: profile,
|
||||
question: q,
|
||||
history: JSON.stringify(history.slice(-PROFILES[profile].turns * 2)),
|
||||
kind: PROFILES[profile].kind ? $('vv-ai-kind').value : '',
|
||||
think: $('vv-ai-think').checked ? '1' : '0',
|
||||
});
|
||||
res = fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
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; }
|
||||
history.push({ role: 'user', content: q });
|
||||
poll(d.token, Date.now());
|
||||
})
|
||||
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
|
||||
}
|
||||
|
||||
function finish() {
|
||||
busy = false;
|
||||
$('vv-ai-send').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);
|
||||
history.push({ role: 'assistant', content: j.answer });
|
||||
const cap = PROFILES[profile].turns * 2;
|
||||
if (history.length > cap) history = history.slice(-cap);
|
||||
fetch(API, { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||||
finish(); loadBanner(); loadTokens(); 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(); });
|
||||
}
|
||||
|
||||
// ── Source viewer ───────────────────────────────────────────────────────
|
||||
window.vvAiOpen = function (path) {
|
||||
$('vv-ai-view-t').textContent = path;
|
||||
$('vv-ai-view-b').textContent = 'Loading…';
|
||||
$('vv-ai-view').classList.add('open');
|
||||
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
|
||||
.then(r => r.json())
|
||||
.then(d => { $('vv-ai-view-b').textContent = d.ok ? d.content
|
||||
: (d.error || 'Could not read this file.'); })
|
||||
.catch(e => { $('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
|
||||
};
|
||||
window.vvAiCloseView = function () { $('vv-ai-view').classList.remove('open'); };
|
||||
window.vvAiCite = function (n) {
|
||||
const s = lastSources[n - 1];
|
||||
if (s && s.path) vvAiOpen(s.path);
|
||||
};
|
||||
|
||||
// ── Profiles ────────────────────────────────────────────────────────────
|
||||
// Switching clears the conversation history sent to the model but leaves the transcript on
|
||||
// screen. Carrying turns across a profile change would mean feeding cited, retrieval-grounded
|
||||
// answers into a mode that has no retrieval — the model would keep referring to sources it can
|
||||
// no longer see. The visible marker is so the transcript still reads honestly afterwards.
|
||||
function setProfile(p) {
|
||||
if (!PROFILES[p] || p === profile) return;
|
||||
profile = p;
|
||||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.prof === p));
|
||||
$('vv-ai-prof-hint').textContent = PROFILES[p].hint;
|
||||
$('vv-ai-kind').style.display = PROFILES[p].kind ? '' : 'none';
|
||||
|
||||
if (history.length) {
|
||||
const label = document.querySelector('.vv-ai-prof[data-prof="' + p + '"]').textContent;
|
||||
chat().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(label)
|
||||
+ ' — earlier turns are no longer carried</div>'));
|
||||
scroll();
|
||||
}
|
||||
history = [];
|
||||
$('vv-ai-input').focus();
|
||||
}
|
||||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||||
b.addEventListener('click', () => setProfile(b.dataset.prof)));
|
||||
$('vv-ai-prof-hint').textContent = PROFILES[profile].hint;
|
||||
|
||||
// ── Memory panel ────────────────────────────────────────────────────────
|
||||
// Live character count against the cap, because the budget is the whole point: this text is
|
||||
// prepended to every single turn and competes with retrieval for a 16k context.
|
||||
@@ -993,31 +663,21 @@ if (is_dir('/var/log/varaverk')) {
|
||||
$('vv-ai-mem-text').addEventListener('input', memCount);
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────
|
||||
$('vv-ai-send').addEventListener('click', send);
|
||||
$('vv-ai-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
|
||||
// The full-size instance. onTurn is why the banner and the ledger stay current without either
|
||||
// of them polling for it: the totals only move when a turn completes, and the chat is the
|
||||
// thing that knows when that was.
|
||||
chat = VvAiChat({
|
||||
prefix: 'vv-ai',
|
||||
profile: 'varaverk', // the strict profile is the one you land on
|
||||
kindEl: 'vv-ai-kind',
|
||||
thinkEl: 'vv-ai-think',
|
||||
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
|
||||
+ 'documentation, with sources.',
|
||||
onTurn: () => { loadBanner(); loadTokens(); },
|
||||
onChats: id => { if (chatList) chatList.setActive(id); },
|
||||
});
|
||||
$('vv-ai-clear').addEventListener('click', () => {
|
||||
history = []; lastSources = [];
|
||||
chat().innerHTML = '<div class="vv-ai-empty">Ask Varaverk about itself.<br>'
|
||||
+ "Answers come only from this installation's own documentation, with sources.</div>";
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
|
||||
|
||||
// Surface any script error on this tab into the transcript. Without it a throw anywhere in
|
||||
// the page is invisible unless the console happens to be open, which is how a silent hang
|
||||
// survives a diagnosis session.
|
||||
window.addEventListener('error', e => {
|
||||
if (!busy) return;
|
||||
addError('Script error: ' + (e.message || 'unknown')
|
||||
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
|
||||
finish();
|
||||
});
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
if (!busy) return;
|
||||
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
|
||||
finish();
|
||||
});
|
||||
chatList = VvAiChatList({ into: 'vv-ai-chats', chat });
|
||||
|
||||
// Proves to the page itself that this copy of the script is the one running, and that its
|
||||
// click handler is attached. If the marker still reads NOT RUNNING, the browser is executing
|
||||
@@ -1026,14 +686,15 @@ if (is_dir('/var/log/varaverk')) {
|
||||
if (live) { live.style.color = '#4a4a4a'; live.textContent = '· JS live'; }
|
||||
|
||||
// Old copies of this script survive a tab swap and keep their timers. Tearing the previous
|
||||
// one down stops N banners polling in parallel, and stops a stale closure's busy flag from
|
||||
// being the thing the user is actually looking at.
|
||||
// one down stops N banners polling in parallel. The chat instance tears itself down the same
|
||||
// way, keyed on its prefix, so it is not handled here.
|
||||
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
|
||||
const bannerTimer = setInterval(loadBanner, 30000);
|
||||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
|
||||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); };
|
||||
|
||||
loadBanner();
|
||||
loadTokens();
|
||||
loadBugs();
|
||||
chatList.reload();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user