<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   AI tab. Asks Varaverk about itself — a grounded chat over the documentation index, with a
//   status banner covering index health, model residency and GPU state.
//
// OPERATIONAL MODEL
//   Token and poll, not streaming. A turn takes 25-76 seconds, so the composer POSTs to
//   api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
//   the api layer on one response convention; see api/ai.php for why SSE was declined.
//
//   The tab is only reachable when AI_ENABLED is true. Varaverk.page omits it from the tab
//   list and rejects it server-side, and api/ai.php refuses ask independently — hiding a link
//   is not access control.
//
// DESIGN PRINCIPLES
//   The banner leads with offload, not with size.
//       41/41 layers at 100% GPU is the difference between 74 tok/s and 19 on this card, and
//       nothing else in the WebGUI surfaces it. Index size is interesting; residency is
//       actionable.
//
//   Staleness is stated, not implied.
//       An index older than the newest tracked file will answer confidently from code that has
//       since changed — the one failure a grounded answer cannot reveal on its own.
//
//   Sources are the point, not a footnote.
//       Every answer lists what it was built from, with scores, and each source opens the file
//       it came from. Retrieval you can audit is the reason to build this here rather than use
//       a general chat client.
//
//   Reasoning is kept and collapsed.
//       qwen3 emits substantial thinking, often more useful than the answer for a judgement
//       call. Hidden by default so it does not bury the answer; one click away because
//       discarding it would lose the best part.
//
// OPERATIONAL SAFEGUARDS
//   Every rendered string is escaped before it reaches the DOM.
//       Answers, reasoning, source paths and headings are all model or file derived. The
//       minimal markdown pass runs strictly after escaping, so no input can introduce markup.
//
//   Conversation history is bounded client-side and again server-side.
//       The page sends the last few turns; api/ai.php caps them regardless. The model is only
//       fully offloaded at 16384 context, and unbounded history would cross that silently.
//
//   Polling stops on a terminal state, on error, and on a wall-clock ceiling.
//       A worker that dies without writing would otherwise be polled forever.
//
//   Read-only with respect to the system. Nothing here runs a script, edits conf, or changes
//   any Varaverk state — it asks questions about documentation.
//
// RENDERS
//   Status banner (index, model residency, GPU, staleness), chat transcript with collapsible
//   reasoning and audited sources, composer with retrieval-scope and reasoning controls,
//   source viewer overlay
//
// DEPENDS ON
//   api/ai.php          stats / ask / poll / clear
//   api/readscript.php  source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
?>
<style>
#vv-ai-wrap { display:flex; flex-direction:column; gap:12px; }

/* ── Banner ─────────────────────────────────────────────────────────────── */
.vv-ai-banner { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1px;
                background:#1a1a1a; border:1px solid #262626; border-radius:6px; overflow:hidden; }
.vv-ai-stat   { background:#0e0e0e; padding:10px 12px; display:flex; flex-direction:column; gap:3px; }
.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; }

/* ── 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-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; }

/* ── Health + loaded models ─────────────────────────────────────────────── */
.vv-ai-diag { display:grid; grid-template-columns:1fr 1fr; gap:1px; background:#1a1a1a;
              border:1px solid #262626; border-radius:6px; overflow:hidden; }
@media (max-width:900px) { .vv-ai-diag { grid-template-columns:1fr; } }
.vv-ai-diag-col { background:#0e0e0e; padding:9px 12px; }
.vv-ai-diag-h { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a;
                margin-bottom:6px; display:flex; align-items:center; gap:7px; }
.vv-ai-chk { display:flex; align-items:flex-start; gap:7px; font-size:11px; padding:2px 0; line-height:1.5; }
.vv-ai-chk-i { flex-shrink:0; font-size:11px; width:12px; }
.vv-ai-chk-l { color:#8a8a8a; flex-shrink:0; }
.vv-ai-chk-d { color:#5a5a5a; }
.vv-ai-chk-f { color:#8a6a3a; display:block; font-size:10px; margin-top:1px; }
.vv-ai-mdl { display:flex; align-items:center; gap:7px; font-size:11px; padding:2px 0; }
.vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; }
.vv-ai-none { font-size:11px; color:#3a3a3a; 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; }
.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>

<div id="vv-ai-wrap">

  <div class="vv-ai-banner" id="vv-ai-banner"></div>

  <div class="vv-ai-diag">
    <div class="vv-ai-diag-col">
      <div class="vv-ai-diag-h"><span id="vv-ai-health-sum"></span> System checks</div>
      <div id="vv-ai-health"></div>
    </div>
    <div class="vv-ai-diag-col">
      <div class="vv-ai-diag-h">Loaded models</div>
      <div id="vv-ai-models"></div>
    </div>
  </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.
    </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">
      <select id="vv-ai-kind" title="Restrict retrieval to one kind of source">
        <option value="">All sources</option>
        <option value="readme">README — what things are</option>
        <option value="manual">Manual — how to do things</option>
        <option value="header">Script headers</option>
        <option value="template">Conf templates</option>
        <option value="doc">Design notes</option>
      </select>
      <label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> reasoning</label>
      <button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
      <span class="vv-ai-hint">Ctrl+Enter to send · history capped at 3 turns</span>
      <button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
    </div>
  </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 MAXTURN = 3;
  const POLL_MS = 1200;
  const POLL_CEIL = 300000;          // stop polling a worker that never wrote a terminal state

  let history = [];                  // {role, content} — trimmed to MAXTURN pairs
  let busy    = false;
  let lastSources = [];

  const $ = id => document.getElementById(id);
  const esc = s => String(s == null ? '' : s)
      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

  // ── Banner ──────────────────────────────────────────────────────────────
  function stat(label, value, sub, cls) {
    return `<div class="vv-ai-stat"><div class="vv-ai-stat-l">${esc(label)}</div>`
         + `<div class="vv-ai-stat-v ${cls||''}">${esc(value)}</div>`
         + `<div class="vv-ai-stat-s">${esc(sub||'')}</div></div>`;
  }
  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 ago';
    if (d < 86400) return Math.floor(d/3600)+'h ago';
    return Math.floor(d/86400)+'d ago';
  }
  function renderBanner(s) {
    const ix = s.index || {}, rt = s.runtime || {};
    let html = '';

    // Residency first — it is the number that silently costs 4x throughput.
    if (!rt.reachable) {
      html += stat('Model', 'unreachable', s.url || '', 'vv-ai-bad');
    } else if (!rt.loaded) {
      html += stat('Model', 'not loaded', 'loads on first question', 'vv-ai-warn');
    } else {
      const p = rt.offload_pct;
      html += stat('GPU offload', p === null ? '—' : p + '%',
                   p === 100 ? 'fully resident' : 'layers on CPU — slow',
                   p === 100 ? 'vv-ai-ok' : 'vv-ai-warn');
    }
    html += stat('Context', rt.context ? rt.context.toLocaleString() : '—',
                 (s.model || '').replace(/^hf\.co\/[^/]+\//,''));

    if (!ix.exists) {
      html += stat('Index', 'not built', 'run AI/ai_index.sh', 'vv-ai-bad');
    } else {
      html += stat('Index', ix.chunks.toLocaleString() + ' chunks',
                   ix.files + ' files · ' + (ix.size/1048576).toFixed(1) + ' MB');
      html += stat('Built', ago(ix.built),
                   ix.stale ? 'source newer — reindex' : 'current',
                   ix.stale ? 'vv-ai-warn' : 'vv-ai-ok');
    }
    if (rt.gpu) {
      html += stat('VRAM', (rt.gpu.mem_used/1024).toFixed(1) + '/' + (rt.gpu.mem_total/1024).toFixed(1) + ' GB',
                   rt.gpu.name + ' · ' + rt.gpu.util + '% util');
    }
    $('vv-ai-banner').innerHTML = html;
    renderHealth(s.health || []);
    renderModels(s.loaded, s.model);
  }

  // ✓ / ! / ✗ per check. Remedy shown inline on anything not ok — the point is to name the
  // setting that is wrong, not to report that something failed.
  const ICON = { ok: ['✓','vv-ai-ok'], warn: ['!','vv-ai-warn'], bad: ['✗','vv-ai-bad'] };
  function renderHealth(checks) {
    if (!checks.length) { $('vv-ai-health').innerHTML = '<div class="vv-ai-none">no checks</div>'; return; }
    let bad = 0, warn = 0, html = '';
    checks.forEach(c => {
      if (c.state === 'bad') bad++; else if (c.state === 'warn') warn++;
      const [ic, cl] = ICON[c.state] || ICON.warn;
      html += `<div class="vv-ai-chk"><span class="vv-ai-chk-i ${cl}">${ic}</span>`
            + `<span><span class="vv-ai-chk-l">${esc(c.label)}</span> `
            + `<span class="vv-ai-chk-d">— ${esc(c.detail)}</span>`
            + (c.fix ? `<span class="vv-ai-chk-f">→ ${esc(c.fix)}</span>` : '')
            + `</span></div>`;
    });
    $('vv-ai-health').innerHTML = html;
    const sum = $('vv-ai-health-sum');
    if (bad)       sum.innerHTML = `<span class="vv-ai-bad">✗ ${bad} problem${bad>1?'s':''}</span>`;
    else if (warn) sum.innerHTML = `<span class="vv-ai-warn">! ${warn} warning${warn>1?'s':''}</span>`;
    else           sum.innerHTML = `<span class="vv-ai-ok">✓ all good</span>`;
  }

  function renderModels(loaded, active) {
    const box = $('vv-ai-models');
    if (loaded === null || loaded === undefined) {
      box.innerHTML = '<div class="vv-ai-none">Ollama unreachable</div>'; return;
    }
    if (!loaded.length) {
      box.innerHTML = '<div class="vv-ai-none">none resident — loads on first use</div>'; return;
    }
    let html = '';
    loaded.forEach(m => {
      const full = m.offload === 100;
      const [ic, cl] = full ? ICON.ok : ICON.warn;
      html += `<div class="vv-ai-mdl"><span class="vv-ai-chk-i ${cl}">${ic}</span>`
            + `<span class="vv-ai-mdl-n"${m.name===active?' style="color:#a8c8a8"':''}>`
            + esc(m.name.replace(/^hf\.co\/[^/]+\//,'')) + `</span>`
            + `<span class="vv-ai-mdl-m">${m.offload===null?'—':m.offload+'% GPU'}`
            + `${m.context?' · '+m.context.toLocaleString()+' ctx':''} · ${(m.vram/1073741824).toFixed(1)}GB</span></div>`;
    });
    box.innerHTML = html;
  }
  function loadBanner() {
    fetch(API + '?action=stats').then(r => r.json())
      .then(d => { if (d.ok) renderBanner(d.stats); })
      .catch(() => {});
  }

  // ── 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();
  }
  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></div></div>`);
    chat().appendChild(n); scroll();
  }
  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>`;
    }
    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() {
    if (busy) 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();

    const fd = new FormData();
    fd.append('action', 'ask');
    fd.append('question', q);
    fd.append('history', JSON.stringify(history.slice(-MAXTURN * 2)));
    fd.append('kind', $('vv-ai-kind').value);
    fd.append('think', $('vv-ai-think').checked ? '1' : '0');

    fetch(API, { method: 'POST', body: fd }).then(r => r.json()).then(d => {
      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); finish(); });
  }

  function finish() { busy = false; $('vv-ai-send').disabled = false; }

  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 });
        if (history.length > MAXTURN * 2) history = history.slice(-MAXTURN * 2);
        const fd = new FormData(); fd.append('action','clear'); fd.append('token',token);
        fetch(API, { method:'POST', body: fd }).catch(() => {});
        finish(); loadBanner(); 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);
  };

  // ── Wiring ──────────────────────────────────────────────────────────────
  $('vv-ai-send').addEventListener('click', send);
  $('vv-ai-input').addEventListener('keydown', e => {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
  });
  $('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(); });

  loadBanner();
  setInterval(loadBanner, 30000);
})();
</script>
