Put every decision the assistant is waiting on in one place
Findings and proposed memory were two cards asking the same kind of question, so checking one was never enough.
This commit is contained in:
+265
-212
@@ -159,10 +159,41 @@ 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; }
|
||||
|
||||
/* ── Repair findings ────────────────────────────────────────────────────── */
|
||||
/* Deliberately not styled like the bug reports above. A bug report is a note to send someone
|
||||
/* ── Findings & proposals ───────────────────────────────────────────────── */
|
||||
/* One card, two sources. A repair finding and a proposed memory are the same kind of object from
|
||||
where the operator sits — the assistant asking for a decision rather than reporting a fact —
|
||||
and two cards meant two places to check for the same errand. They keep separate stores, gates
|
||||
and endpoints; only the presentation is shared.
|
||||
|
||||
Deliberately not styled like the bug reports above. A bug report is a note to send someone
|
||||
else; a finding is a decision waiting on the operator, and the row carries buttons that write
|
||||
conf. The left border colours by severity so a page of them is scannable without reading. */
|
||||
/* Which source a row came from, said on the row rather than inferred from its shape. Colour
|
||||
carries it at a glance and the word carries it for anyone the colour does not reach. */
|
||||
.vv-ai-src-tag { font-size:9px; letter-spacing:.06em; text-transform:uppercase; border-radius:3px;
|
||||
padding:1px 6px; border:1px solid; flex-shrink:0; }
|
||||
.vv-ai-src-tag.repair { color:#d8a15a; border-color:#4a3a1e; background:#1a1408; }
|
||||
.vv-ai-src-tag.memory { color:#7d9be8; border-color:#26324a; background:#0d1220; }
|
||||
|
||||
/* Filter pills, loosely the shape the Monitor scripts card uses: a count you can click. All is an
|
||||
explicit pill rather than "click the active one again to clear" — deselecting to get everything
|
||||
back is a gesture you have to already know. A source with nothing in it still shows its pill, so
|
||||
"no proposals" and "proposals are switched off" do not look like the same empty card. */
|
||||
.vv-ai-qf { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:9px; }
|
||||
.vv-ai-qf-b { font-size:10px; padding:2px 9px; border-radius:10px; cursor:pointer;
|
||||
background:#111; border:1px solid #262626; color:#6a6a6a; font-family:inherit; }
|
||||
.vv-ai-qf-b:hover { color:#9a9a9a; border-color:#3a3a3a; }
|
||||
.vv-ai-qf-b.on { color:#c8c8c8; border-color:#4a4a4a; background:#1c1c1c; font-weight:700; }
|
||||
.vv-ai-qf-b.on.repair { color:#d8a15a; border-color:#6a4f26; background:#1a1408; }
|
||||
.vv-ai-qf-b.on.memory { color:#7d9be8; border-color:#33456a; background:#0d1220; }
|
||||
.vv-ai-qf-n { opacity:.7; }
|
||||
|
||||
/* One gate line per source, because they are genuinely separate switches and joining them into a
|
||||
sentence would invent a relationship that does not exist. Each hides when its source is filtered
|
||||
out, so the line you are reading always describes the rows underneath it. */
|
||||
.vv-ai-qg { font-size:10px; color:#4a4a4a; margin-bottom:8px; line-height:1.7; }
|
||||
.vv-ai-qg > div { display:flex; gap:7px; align-items:baseline; }
|
||||
.vv-ai-qg b { font-weight:normal; color:#5a5a5a; min-width:52px; flex-shrink:0; }
|
||||
.vv-ai-fnd { border-left:2px solid #3a3a3a; background:#0d0d0d; border-radius:0 3px 3px 0;
|
||||
padding:8px 10px; margin-bottom:8px; }
|
||||
.vv-ai-fnd.sev-error { border-left-color:#7a3040; background:#140c0e; }
|
||||
@@ -241,31 +272,20 @@ if (is_dir('/var/log/varaverk')) {
|
||||
Unlike the bugs card it does not hide itself when empty. "Nothing found, last swept 6
|
||||
minutes ago" is the single most useful thing this card ever says, and a card that vanishes
|
||||
on good news cannot say it — it just leaves a gap that reads as broken. -->
|
||||
<div class="vv-ai-tok" id="vv-ai-fnd-wrap">
|
||||
<div class="vv-ai-tok" id="vv-ai-q-wrap">
|
||||
<div class="vv-ai-diag-col" style="grid-column:1/-1">
|
||||
<div class="vv-ai-diag-h">
|
||||
<span id="vv-ai-fnd-sum"></span> Self-repair findings
|
||||
<span class="vv-ai-set-sum" style="margin-left:auto" id="vv-ai-fnd-gate"></span>
|
||||
<button class="vv-ai-btn ghost" id="vv-ai-fnd-all" type="button"
|
||||
style="padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show closed</button>
|
||||
<span id="vv-ai-q-sum"></span> Findings & proposals
|
||||
<!-- The gate lines used to sit here as one right-aligned span. Two sources means two
|
||||
gates, and two of them in a header is a sentence; they moved under the pills. -->
|
||||
<button class="vv-ai-btn ghost" id="vv-ai-q-closed" type="button"
|
||||
style="margin-left:auto;padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show decided</button>
|
||||
</div>
|
||||
<div id="vv-ai-fnd"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proposed memory. Sits beside findings deliberately: both are the assistant asking for a
|
||||
decision rather than reporting a fact, and both are worthless if the answer is a click the
|
||||
operator never sees. Shown even when empty for the same reason the findings card is — the
|
||||
gate line is the news when there is nothing else. -->
|
||||
<div class="vv-ai-tok" id="vv-ai-mem-wrap">
|
||||
<div class="vv-ai-diag-col" style="grid-column:1/-1">
|
||||
<div class="vv-ai-diag-h">
|
||||
<span id="vv-ai-mem-sum"></span> Proposed memory
|
||||
<span class="vv-ai-set-sum" style="margin-left:auto" id="vv-ai-mem-gate"></span>
|
||||
<button class="vv-ai-btn ghost" id="vv-ai-mem-all" type="button"
|
||||
style="padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show decided</button>
|
||||
</div>
|
||||
<div id="vv-ai-mem-list"></div>
|
||||
<!-- Rendered rather than written out, because the counts are part of the label and a pill
|
||||
with no count would be a filter you cannot judge before clicking. -->
|
||||
<div class="vv-ai-qf" id="vv-ai-q-filters"></div>
|
||||
<div class="vv-ai-qg" id="vv-ai-q-gates"></div>
|
||||
<div id="vv-ai-queue"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -623,27 +643,44 @@ vv_ai_chat_markup('vv-ai', [
|
||||
.then(() => loadBugs()).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Repair findings ─────────────────────────────────────────────────────
|
||||
// The sweep files these every 15 minutes and, until this card, the only way to read one was
|
||||
// ai_repair_sweep.sh --status over SSH. Every button here is an answer the operator is giving
|
||||
// to something the system noticed — including Fix, which writes conf through the guarded path.
|
||||
let fndAll = false, fndRows = [];
|
||||
// ── Findings & proposals ────────────────────────────────────────────────
|
||||
// Two stores, two gates, two endpoints, one card. A repair finding and a proposed memory are
|
||||
// the same object from where the operator sits — the assistant asking for a decision rather
|
||||
// than reporting a fact — and keeping them in separate cards meant two places to check for the
|
||||
// same errand, with the empty one still charging a header for saying nothing.
|
||||
//
|
||||
// Rows are normalised to a common shape on load and interleaved newest-first. Everything below
|
||||
// draws from that shape, so a third source would be a loader and a row renderer, not a card.
|
||||
// What is deliberately NOT shared: the stores, the gates, the endpoints and the confirmations.
|
||||
// Merging the presentation of two decisions must not merge the decisions.
|
||||
let qFilter = 'all'; // all | memory | repair
|
||||
let qClosed = false; // include decided and closed rows
|
||||
let qRows = []; // normalised, newest first
|
||||
let qGates = {}; // one line per source, plus whether each reads as healthy
|
||||
|
||||
// Labels are the page's; meanings are not. The title on each button is the server's own
|
||||
// description of that action, so the wording an operator hovers is the same wording the chat
|
||||
// uses for the same row.
|
||||
const FND_LABEL = { fix: 'Fix', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' };
|
||||
|
||||
// Which actions take a second press, and what the button says while it is waiting for it.
|
||||
// Fix writes conf; dismiss is the one state the sweep will never reopen on its own however
|
||||
// many times the fault comes back. Ack and reopen are both reversible and go on one press.
|
||||
const FND_ARM = { fix: 'Confirm write', dismiss: 'Confirm — permanent' };
|
||||
// Which presses take a second one, and what the button says while it waits. Source-aware on
|
||||
// purpose: repair's Fix writes conf and its Dismiss is the one state the sweep will never
|
||||
// reopen on its own, while memory's Keep is the only route by which model-written text reaches
|
||||
// a future prompt. Memory's Dismiss writes nothing anywhere, so a confirmation there is
|
||||
// ceremony — a single map keyed on the action name alone would have armed it by accident.
|
||||
function qArmLabel(row, act) {
|
||||
if (row.src === 'repair') {
|
||||
return act === 'fix' ? 'Confirm write'
|
||||
: act === 'dismiss' ? 'Confirm — permanent' : null;
|
||||
}
|
||||
return act === 'accept' ? 'Confirm keep' : null;
|
||||
}
|
||||
let fndArmTimer = null;
|
||||
|
||||
// Put every armed button back, optionally sparing the one being pressed right now.
|
||||
function fndDisarm(keep) {
|
||||
clearTimeout(fndArmTimer);
|
||||
$('vv-ai-fnd').querySelectorAll('button[data-armed="1"]').forEach(b => {
|
||||
$('vv-ai-queue').querySelectorAll('button[data-armed="1"]').forEach(b => {
|
||||
if (b === keep) return;
|
||||
b.dataset.armed = '';
|
||||
if (b.dataset.label) b.textContent = b.dataset.label;
|
||||
@@ -656,46 +693,117 @@ vv_ai_chat_markup('vv-ai', [
|
||||
});
|
||||
}
|
||||
|
||||
function loadFindings() {
|
||||
fetch(API + '?action=findings' + (fndAll ? '&all=1' : '')).then(r => r.json())
|
||||
.then(d => { if (d.ok) renderFindings(d); })
|
||||
.catch(() => {});
|
||||
// Held so a filter click can redraw without refetching. The gate lines and the open counts live
|
||||
// on the payloads rather than on the normalised rows, so the payloads are what has to be kept.
|
||||
let qLastF = null, qLastM = null;
|
||||
|
||||
// Both in parallel, and a failure of either is survivable: the card renders what it got and the
|
||||
// missing source reports that it could not be read, rather than the whole card going blank
|
||||
// because one endpoint was slow.
|
||||
function loadQueue() {
|
||||
Promise.all([
|
||||
fetch(API + '?action=findings' + (qClosed ? '&all=1' : '')).then(r => r.json()).catch(() => null),
|
||||
fetch(API + '?action=mem_proposals').then(r => r.json()).catch(() => null),
|
||||
]).then(([f, m]) => { qLastF = f; qLastM = m; renderQueue(); });
|
||||
}
|
||||
|
||||
function renderFindings(d) {
|
||||
fndRows = d.findings || [];
|
||||
const rep = d.repair || {}, c = d.counts || {};
|
||||
function renderQueue() {
|
||||
const f = qLastF, m = qLastM;
|
||||
const fOk = !!(f && f.ok), mOk = !!(m && m.ok);
|
||||
const rep = (fOk && f.repair) || {};
|
||||
const fc = (fOk && f.counts) || {};
|
||||
const L = (mOk && m.learned) || {};
|
||||
|
||||
// Gate state is stated on the card rather than left to be inferred from an empty list. An
|
||||
// empty list means "nothing found" when repair is on and "nothing is looking" when it is off,
|
||||
// and those are opposite pieces of news.
|
||||
const gate = [];
|
||||
gate.push(rep.enabled ? (rep.autofix ? 'repair on · autofix on'
|
||||
: 'repair on · detect only')
|
||||
: 'repair off — nothing is looking');
|
||||
if (rep.last) gate.push('swept ' + ago(rep.last));
|
||||
$('vv-ai-fnd-gate').innerHTML = rep.enabled
|
||||
? esc(gate.join(' · '))
|
||||
: `<span class="vv-ai-warn">${esc(gate.join(' · '))}</span>`;
|
||||
// Gate state is stated rather than left to be inferred from an empty list. An empty list means
|
||||
// "nothing found" when the gate is on and "nothing is looking" when it is off, and those are
|
||||
// opposite pieces of news. Kept as two lines because they are genuinely separate switches —
|
||||
// joining them into one sentence would invent a relationship that does not exist.
|
||||
qGates = {
|
||||
repair: !fOk ? 'could not be read'
|
||||
: rep.enabled ? ['repair on', rep.autofix ? 'autofix on' : 'detect only',
|
||||
rep.last ? 'swept ' + ago(rep.last) : null].filter(Boolean).join(' · ')
|
||||
: 'off — nothing is looking',
|
||||
repairOk: fOk && !!rep.enabled,
|
||||
memory: !mOk ? 'could not be read'
|
||||
: m.enabled ? [m.auto ? 'learning on · auto-accept on' : 'learning on · proposals held for you',
|
||||
L.max ? 'learned ' + (L.chars || 0) + '/' + L.max + ' chars' : null]
|
||||
.filter(Boolean).join(' · ')
|
||||
: 'off — nothing is proposing',
|
||||
// Auto-accept reads as a warning on purpose: it is the one setting that lets model-written
|
||||
// text reach a prompt with nobody having looked at it.
|
||||
memoryOk: mOk && !!m.enabled && !m.auto,
|
||||
};
|
||||
|
||||
// needs_operator is counted separately from open because it is the one that is actually
|
||||
// waiting on a person — an open finding may still be repaired by the next sweep.
|
||||
const sum = $('vv-ai-fnd-sum');
|
||||
if (c.needs_operator) sum.innerHTML = `<span class="vv-ai-warn">${c.needs_operator} need${c.needs_operator > 1 ? '' : 's'} you</span>`;
|
||||
else if (c.open) sum.innerHTML = `<span class="vv-ai-warn">${c.open} open</span>`;
|
||||
else sum.innerHTML = `<span class="vv-ai-ok">✓ nothing open</span>`;
|
||||
const fRows = (fOk && f.findings ? f.findings : []).map(x => ({
|
||||
src: 'repair', id: x.id, ts: x.last || 0,
|
||||
closed: ['open', 'needs_operator'].indexOf(x.state) === -1, raw: x,
|
||||
}));
|
||||
const mSrc = mOk ? (qClosed ? (m.recent || []) : (m.open || [])) : [];
|
||||
const mRows = mSrc.map(x => ({
|
||||
src: 'memory', id: x.id, ts: x.created || 0,
|
||||
closed: x.state !== 'open', raw: x,
|
||||
}));
|
||||
|
||||
if (!fndRows.length) {
|
||||
$('vv-ai-fnd').innerHTML = fndAll
|
||||
? '<div class="vv-ai-none">nothing filed yet</div>'
|
||||
: '<div class="vv-ai-none">nothing open — the last sweep found no new faults</div>';
|
||||
// Interleaved by recency rather than grouped by source. Grouping would rebuild the two cards
|
||||
// inside one border and put the newest thing anywhere but the top.
|
||||
qRows = fRows.concat(mRows).sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
|
||||
// Counts describe what each pill would actually show, so a pill can be judged before it is
|
||||
// clicked. A source with nothing in it keeps its pill — "no proposals" and "proposals are
|
||||
// switched off" must not look like the same empty card.
|
||||
const nRep = qRows.filter(r => r.src === 'repair').length;
|
||||
const nMem = qRows.filter(r => r.src === 'memory').length;
|
||||
$('vv-ai-q-filters').innerHTML =
|
||||
qPill('all', 'All', qRows.length)
|
||||
+ qPill('memory', 'Memory', nMem)
|
||||
+ qPill('repair', 'Repair', nRep);
|
||||
|
||||
$('vv-ai-q-gates').innerHTML = ['repair', 'memory'].map(k => {
|
||||
if (qFilter !== 'all' && qFilter !== k) return '';
|
||||
const cls = qGates[k + 'Ok'] ? '' : ' class="vv-ai-warn"';
|
||||
return `<div><b>${k === 'repair' ? 'Repair' : 'Memory'}</b>`
|
||||
+ `<span${cls}>${esc(qGates[k])}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
// needs_operator is counted apart from open because it is the one genuinely waiting on a
|
||||
// person — an open finding may still be repaired by the next sweep. Every open proposal is
|
||||
// waiting on a person by definition, so it joins that number rather than the other.
|
||||
const needs = (fc.needs_operator || 0) + (mOk ? (m.open || []).length : 0);
|
||||
const sum = $('vv-ai-q-sum');
|
||||
if (needs) sum.innerHTML = `<span class="vv-ai-warn">${needs} need${needs > 1 ? '' : 's'} you</span>`;
|
||||
else if (fc.open) sum.innerHTML = `<span class="vv-ai-warn">${fc.open} open</span>`;
|
||||
else sum.innerHTML = `<span class="vv-ai-ok">✓ nothing waiting</span>`;
|
||||
|
||||
const shown = qFilter === 'all' ? qRows : qRows.filter(r => r.src === qFilter);
|
||||
if (!shown.length) {
|
||||
$('vv-ai-queue').innerHTML =
|
||||
`<div class="vv-ai-none">${esc(qEmptyText())}</div>`;
|
||||
return;
|
||||
}
|
||||
$('vv-ai-queue').innerHTML = shown.map(r =>
|
||||
r.src === 'repair' ? qFindingHtml(r) : qMemoryHtml(r)).join('');
|
||||
}
|
||||
|
||||
$('vv-ai-fnd').innerHTML = fndRows.map(f => {
|
||||
const closed = ['open', 'needs_operator'].indexOf(f.state) === -1;
|
||||
// Says which of several different nothings this is. "Nothing here" over a filtered view that
|
||||
// has hidden the only row is the kind of empty state that gets reported as a bug.
|
||||
function qEmptyText() {
|
||||
if (qFilter === 'memory') return qClosed ? 'nothing proposed yet'
|
||||
: 'no proposals waiting on you';
|
||||
if (qFilter === 'repair') return qClosed ? 'nothing filed yet'
|
||||
: 'nothing open — the last sweep found no new faults';
|
||||
return qClosed ? 'nothing filed or proposed yet' : 'nothing waiting on you';
|
||||
}
|
||||
|
||||
function qPill(key, label, n) {
|
||||
const on = qFilter === key;
|
||||
return `<button type="button" class="vv-ai-qf-b${on ? ' on ' + key : ''}" data-qf="${key}">`
|
||||
+ `${esc(label)} <span class="vv-ai-qf-n">${n}</span></button>`;
|
||||
}
|
||||
|
||||
function qFindingHtml(row) {
|
||||
const f = row.raw;
|
||||
const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
|
||||
closed ? f.state : null].filter(Boolean).join(' · ');
|
||||
row.closed ? f.state : null].filter(Boolean).join(' · ');
|
||||
|
||||
// Buttons come from what the server offered for this row and nothing else. cancel is the
|
||||
// exception and is dropped: "leave it alone for now" writes nothing by design, which in a
|
||||
@@ -705,8 +813,9 @@ vv_ai_chat_markup('vv-ai', [
|
||||
+ `data-id="${esc(f.id)}" title="${esc(f.actions[a])}">${esc(FND_LABEL[a] || a)}</button>`
|
||||
).join('');
|
||||
|
||||
return `<div class="vv-ai-fnd sev-${esc(f.severity || 'warn')}${closed ? ' closed' : ''}">
|
||||
return `<div class="vv-ai-fnd sev-${esc(f.severity || 'warn')}${row.closed ? ' closed' : ''}">
|
||||
<div class="vv-ai-fnd-h">
|
||||
<span class="vv-ai-src-tag repair">repair</span>
|
||||
<span class="vv-ai-fnd-s">${esc(f.subject)}</span>
|
||||
<span class="vv-ai-fnd-k" title="${esc(f.kind_label || '')}">${esc(f.kind)}</span>
|
||||
<span class="vv-ai-fnd-m">${esc(meta)}</span>
|
||||
@@ -719,39 +828,67 @@ vv_ai_chat_markup('vv-ai', [
|
||||
+ (f.proven ? '' : ' <span class="vv-ai-warn">· unproven</span>') + `</div>`
|
||||
: ''}
|
||||
${f.note ? `<div class="vv-ai-fnd-n">${esc(f.note)}</div>` : ''}
|
||||
<div class="vv-ai-fnd-a">${acts}<span class="vv-ai-fnd-msg" data-msg="${esc(f.id)}"></span></div>
|
||||
<div class="vv-ai-fnd-a">${acts}<span class="vv-ai-fnd-msg" data-msg></span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Delegated, so the markup carries no inline handler and nothing has to be escaped into an
|
||||
// attribute that runs as code.
|
||||
$('vv-ai-fnd').addEventListener('click', e => {
|
||||
const btn = e.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id, act = btn.dataset.act;
|
||||
const f = fndRows.find(x => x.id === id);
|
||||
if (!f) return;
|
||||
function qMemoryHtml(row) {
|
||||
const r = row.raw;
|
||||
const meta = [r.id, ago(r.created), r.profile || null,
|
||||
row.closed ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
|
||||
// The question is the provenance. A line read three weeks from now is judged by what was
|
||||
// being asked when the model decided it was worth keeping.
|
||||
const asked = r.asked ? `<div class="vv-ai-fnd-n">asked: ${esc(r.asked)}</div>` : '';
|
||||
// No severity class: a proposal is not a fault, and colouring it like one would make a card
|
||||
// of ordinary suggestions read as a card of problems.
|
||||
const acts = row.closed ? '' :
|
||||
`<button class="vv-ai-btn" data-mem-act="accept" data-id="${esc(r.id)}" `
|
||||
+ `title="Add this to learned memory">Keep</button>`
|
||||
+ `<button class="vv-ai-btn ghost" data-mem-act="dismiss" data-id="${esc(r.id)}" `
|
||||
+ `title="Never propose this again">Dismiss</button>`;
|
||||
|
||||
const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`);
|
||||
return `<div class="vv-ai-fnd${row.closed ? ' closed' : ''}">
|
||||
<div class="vv-ai-fnd-h">
|
||||
<span class="vv-ai-src-tag memory">memory</span>
|
||||
<span class="vv-ai-fnd-m">${esc(meta)}</span>
|
||||
</div>
|
||||
<pre class="vv-ai-fnd-e">${esc(r.text)}</pre>
|
||||
${asked}
|
||||
<div class="vv-ai-fnd-a">${acts}<span class="vv-ai-fnd-msg" data-msg></span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Fix and dismiss are confirmed in the page, never with confirm(). Both browsers offer
|
||||
// "prevent this page from creating additional dialogs" inside the dialog itself, and once
|
||||
// that is ticked every later confirm() returns false without showing anything — so the
|
||||
// buttons silently decline and read as dead. Unraid swaps tabs by AJAX without reloading,
|
||||
// so the suppression outlives leaving the tab and only a full reload clears it. That is
|
||||
// exactly what happened here: not a broken button, a dialog answering no on its own.
|
||||
if (FND_ARM[act] && btn.dataset.armed !== '1') {
|
||||
// One delegated listener for both sources. The markup carries no inline handler, so nothing has
|
||||
// to be escaped into an attribute that would run as code.
|
||||
$('vv-ai-queue').addEventListener('click', e => {
|
||||
const fb = e.target.closest('button[data-act]');
|
||||
if (fb) { qAct(fb, 'finding_action', fb.dataset.act); return; }
|
||||
const mb = e.target.closest('button[data-mem-act]');
|
||||
if (mb) { qAct(mb, 'mem_proposal_action', mb.dataset.memAct); return; }
|
||||
});
|
||||
|
||||
// Both sources arm, act and fail the same way, because the shape of the interaction is the same:
|
||||
// a press that might be irreversible, a press that confirms it, and a failure that must not be
|
||||
// repainted away before it is read.
|
||||
function qAct(btn, endpointAction, act) {
|
||||
const id = btn.dataset.id;
|
||||
const msg = btn.parentNode.querySelector('[data-msg]');
|
||||
const row = qRows.find(x => x.id === id);
|
||||
if (!row) return;
|
||||
|
||||
// Confirmed in the page, never with confirm(). Both browsers offer "prevent this page from
|
||||
// creating additional dialogs" inside the dialog itself, and once that is ticked every later
|
||||
// confirm() returns false without showing anything — so the buttons silently decline and read
|
||||
// as dead. Unraid swaps tabs by AJAX without reloading, so the suppression outlives leaving
|
||||
// the tab and only a full reload clears it.
|
||||
const arm = qArmLabel(row, act);
|
||||
if (arm && btn.dataset.armed !== '1') {
|
||||
fndDisarm();
|
||||
btn.dataset.armed = '1';
|
||||
btn.dataset.label = btn.textContent;
|
||||
btn.textContent = FND_ARM[act];
|
||||
btn.dataset.label = btn.dataset.label || btn.textContent;
|
||||
btn.textContent = arm;
|
||||
btn.classList.add('armed');
|
||||
if (msg) msg.innerHTML = act === 'fix'
|
||||
? `<span class="vv-ai-warn">Writes the change above to ${esc(f.conf_file)}`
|
||||
+ `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.</span>`
|
||||
: `<span class="vv-ai-warn">Stays closed even when it is seen again. `
|
||||
+ `Use “I know” to be told if it changes. Click again to confirm.</span>`;
|
||||
if (msg) msg.innerHTML = `<span class="vv-ai-warn">${qArmWarning(row, act)}</span>`;
|
||||
// Arming expires on its own so a half-pressed button cannot sit there waiting to be
|
||||
// completed by an unrelated click later.
|
||||
fndArmTimer = setTimeout(fndDisarm, 8000);
|
||||
@@ -763,11 +900,11 @@ vv_ai_chat_markup('vv-ai', [
|
||||
btns.forEach(b => b.disabled = true);
|
||||
if (msg) msg.textContent = 'working…';
|
||||
|
||||
// Reload on success, never on failure. A refused conf write is the case that must not
|
||||
// disappear quietly: the guarded path rolled back, the finding is unchanged, and a reload
|
||||
// would repaint the row identically half a second later and take the error with it. So the
|
||||
// failed row keeps its message, and its buttons go back to reading Fix rather than sitting
|
||||
// at "Confirm write" — a second press should have to arm again, not fire again.
|
||||
// Reload on success, never on failure. A refused conf write and a refused keep are both the
|
||||
// case that must not disappear quietly: nothing changed, and a reload would repaint the row
|
||||
// identically half a second later and take the reason with it. So the failed row keeps its
|
||||
// message, and its button goes back to reading Fix or Keep rather than sitting at "Confirm" —
|
||||
// a second press should have to arm again, not fire again.
|
||||
const failed = text => {
|
||||
btn.dataset.armed = '';
|
||||
if (btn.dataset.label) btn.textContent = btn.dataset.label;
|
||||
@@ -778,123 +915,40 @@ vv_ai_chat_markup('vv-ai', [
|
||||
|
||||
fetch(API, { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: new URLSearchParams({ action: 'finding_action', id, act }) })
|
||||
body: new URLSearchParams({ action: endpointAction, id, act }) })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.ok) loadFindings(); else failed(d.error || 'failed'); })
|
||||
.then(d => { if (d.ok) loadQueue(); else failed(d.error || 'failed'); })
|
||||
.catch(e => failed(String(e)));
|
||||
}
|
||||
|
||||
// What the second press is actually going to do, in the row's own terms.
|
||||
function qArmWarning(row, act) {
|
||||
const f = row.raw;
|
||||
if (act === 'fix') {
|
||||
return `Writes the change above to ${esc(f.conf_file)}`
|
||||
+ `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.`;
|
||||
}
|
||||
if (act === 'dismiss' && row.src === 'repair') {
|
||||
return 'Stays closed even when it is seen again. '
|
||||
+ 'Use “I know” to be told if it changes. Click again to confirm.';
|
||||
}
|
||||
return 'This text joins every future prompt. Click again to confirm.';
|
||||
}
|
||||
|
||||
$('vv-ai-q-filters').addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-qf]');
|
||||
if (!b) return;
|
||||
qFilter = b.dataset.qf;
|
||||
// Redrawn from what is already in hand — a filter is a view of rows already fetched, and a
|
||||
// control this cheap should not cost a request.
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
$('vv-ai-fnd-all').addEventListener('click', () => {
|
||||
fndAll = !fndAll;
|
||||
$('vv-ai-fnd-all').textContent = fndAll ? 'Open only' : 'Show closed';
|
||||
loadFindings();
|
||||
});
|
||||
|
||||
// ── Proposed memory ───────────────────────────────────────────────────────
|
||||
// Accepting is the only route by which text the model wrote reaches a future prompt, so it is
|
||||
// the one control here that has to be deliberate. Same two-press arming the findings card uses
|
||||
// for a conf write, for the same reason.
|
||||
let memAll = false, memRows = [];
|
||||
|
||||
function loadMemProposals() {
|
||||
fetch(API + '?action=mem_proposals').then(r => r.json())
|
||||
.then(d => { if (d.ok) renderMemProposals(d); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function renderMemProposals(d) {
|
||||
memRows = memAll ? (d.recent || []) : (d.open || []);
|
||||
const L = d.learned || {};
|
||||
|
||||
// The gate is the news when the list is empty. "Nothing proposed" and "nothing is proposing"
|
||||
// look identical otherwise, and they are opposite states.
|
||||
const gate = [];
|
||||
gate.push(d.enabled ? (d.auto ? 'learning on · auto-accept on'
|
||||
: 'learning on · proposals held for you')
|
||||
: 'learning off — nothing is proposing');
|
||||
if (L.max) gate.push('learned ' + (L.chars || 0) + '/' + L.max + ' chars');
|
||||
$('vv-ai-mem-gate').innerHTML = (d.enabled && !d.auto)
|
||||
? esc(gate.join(' · '))
|
||||
: `<span class="vv-ai-warn">${esc(gate.join(' · '))}</span>`;
|
||||
|
||||
const openN = (d.open || []).length;
|
||||
$('vv-ai-mem-sum').innerHTML = openN
|
||||
? `<span class="vv-ai-warn">${openN} to review</span>`
|
||||
: `<span class="vv-ai-ok">✓ none waiting</span>`;
|
||||
|
||||
if (!memRows.length) {
|
||||
$('vv-ai-mem-list').innerHTML = memAll
|
||||
? '<div class="vv-ai-none">nothing proposed yet</div>'
|
||||
: '<div class="vv-ai-none">nothing waiting on you</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('vv-ai-mem-list').innerHTML = memRows.map(r => {
|
||||
const decided = r.state !== 'open';
|
||||
const meta = [r.id, ago(r.created), r.profile || null,
|
||||
decided ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
|
||||
// The question is the provenance. A line read three weeks from now is judged by what was
|
||||
// being asked when the model decided it was worth keeping.
|
||||
const asked = r.asked ? `<div class="vv-ai-fnd-r">asked: ${esc(r.asked)}</div>` : '';
|
||||
const acts = decided ? '' :
|
||||
`<button class="vv-ai-btn" data-mem-act="accept" data-mem-id="${esc(r.id)}" `
|
||||
+ `title="Add this to learned memory">Keep</button>`
|
||||
+ `<button class="vv-ai-btn ghost" data-mem-act="dismiss" data-mem-id="${esc(r.id)}" `
|
||||
+ `title="Never propose this again">Dismiss</button>`;
|
||||
|
||||
return `<div class="vv-ai-fnd${decided ? ' closed' : ''}">
|
||||
<div class="vv-ai-fnd-h"><span class="vv-ai-fnd-m">${esc(meta)}</span></div>
|
||||
<pre class="vv-ai-fnd-e">${esc(r.text)}</pre>
|
||||
${asked}
|
||||
<div class="vv-ai-fnd-a">${acts}<span data-msg></span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
$('vv-ai-mem-list').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-mem-act]');
|
||||
if (!btn) return;
|
||||
|
||||
const act = btn.dataset.memAct, id = btn.dataset.memId;
|
||||
const msg = btn.parentNode.querySelector('[data-msg]');
|
||||
|
||||
// Keep arms first. Dismiss does not: it writes nothing to any prompt and is undone by the
|
||||
// model proposing something similar again, so a confirm there would be ceremony.
|
||||
if (act === 'accept' && btn.dataset.armed !== '1') {
|
||||
btn.dataset.armed = '1';
|
||||
btn.dataset.label = btn.dataset.label || btn.textContent;
|
||||
btn.textContent = 'Confirm keep';
|
||||
btn.classList.add('armed');
|
||||
if (msg) msg.textContent = 'this text joins every future prompt';
|
||||
return;
|
||||
}
|
||||
|
||||
const btns = Array.from(btn.parentNode.querySelectorAll('button'));
|
||||
btns.forEach(b => b.disabled = true);
|
||||
if (msg) msg.textContent = 'working…';
|
||||
|
||||
// Reload on success only — same rule as findings. A refused keep (learned memory full) is
|
||||
// the case that must not vanish, and a reload would repaint the row and take the reason away.
|
||||
const failed = text => {
|
||||
btn.dataset.armed = '';
|
||||
if (btn.dataset.label) btn.textContent = btn.dataset.label;
|
||||
btn.classList.remove('armed');
|
||||
btns.forEach(b => b.disabled = false);
|
||||
if (msg) msg.innerHTML = `<span class="vv-ai-bad">${esc(text)}</span>`;
|
||||
};
|
||||
|
||||
fetch(API, { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: new URLSearchParams({ action: 'mem_proposal_action', id, act }) })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.ok) loadMemProposals(); else failed(d.error || 'failed'); })
|
||||
.catch(e => failed(String(e)));
|
||||
});
|
||||
|
||||
$('vv-ai-mem-all').addEventListener('click', () => {
|
||||
memAll = !memAll;
|
||||
$('vv-ai-mem-all').textContent = memAll ? 'Waiting only' : 'Show decided';
|
||||
loadMemProposals();
|
||||
$('vv-ai-q-closed').addEventListener('click', () => {
|
||||
qClosed = !qClosed;
|
||||
$('vv-ai-q-closed').textContent = qClosed ? 'Waiting only' : 'Show decided';
|
||||
// This one does refetch: closed findings are not in hand until the endpoint is asked for them.
|
||||
loadQueue();
|
||||
});
|
||||
|
||||
// ── Token accounting ────────────────────────────────────────────────────
|
||||
@@ -1085,14 +1139,13 @@ vv_ai_chat_markup('vv-ai', [
|
||||
// The sweep runs every 15 minutes, so anything faster than this is polling for news that
|
||||
// cannot have arrived. Five minutes keeps "swept N ago" honest without the card costing
|
||||
// anything to leave open.
|
||||
const fndTimer = setInterval(loadFindings, 300000);
|
||||
const fndTimer = setInterval(loadQueue, 300000);
|
||||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
|
||||
|
||||
loadBanner();
|
||||
loadTokens();
|
||||
loadBugs();
|
||||
loadFindings();
|
||||
loadMemProposals();
|
||||
loadQueue();
|
||||
chatList.reload();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user