From 76ce424f8fa513308dfa0893dd53e2c05bf6ed3b Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Tue, 11 Aug 2026 18:00:44 -0400 Subject: [PATCH] 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. --- Plugin/unraid/pages/ai.php | 517 ++++++++++++++++++++----------------- 1 file changed, 285 insertions(+), 232 deletions(-) diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 863917c..5aafbae 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -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. --> -
+
- Self-repair findings - - + Findings & proposals + +
-
-
-
- - -
-
-
- Proposed memory - - -
-
+ +
+
+
@@ -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,102 +693,202 @@ 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(' · ')) - : `${esc(gate.join(' · '))}`; + // 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 = `${c.needs_operator} need${c.needs_operator > 1 ? '' : 's'} you`; - else if (c.open) sum.innerHTML = `${c.open} open`; - else sum.innerHTML = `✓ nothing open`; + 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 - ? '
nothing filed yet
' - : '
nothing open — the last sweep found no new faults
'; + // 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 `
${k === 'repair' ? 'Repair' : 'Memory'}` + + `${esc(qGates[k])}
`; + }).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 = `${needs} need${needs > 1 ? '' : 's'} you`; + else if (fc.open) sum.innerHTML = `${fc.open} open`; + else sum.innerHTML = `✓ nothing waiting`; + + const shown = qFilter === 'all' ? qRows : qRows.filter(r => r.src === qFilter); + if (!shown.length) { + $('vv-ai-queue').innerHTML = + `
${esc(qEmptyText())}
`; return; } - - $('vv-ai-fnd').innerHTML = fndRows.map(f => { - const closed = ['open', 'needs_operator'].indexOf(f.state) === -1; - const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last), - 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 - // page is spelled "do not click anything". - const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a => - `` - ).join(''); - - return `
-
- ${esc(f.subject)} - ${esc(f.kind)} - ${esc(meta)} -
-
${esc(f.ref)}
-
${esc(f.evidence)}
- ${f.proposed !== null && f.proposed !== undefined - ? `
${esc(f.conf_file)} · ${esc(f.conf_key)} ` - + `${esc(f.observed || '(empty)')} ${esc(f.proposed)}` - + (f.proven ? '' : ' · unproven') + `
` - : ''} - ${f.note ? `
${esc(f.note)}
` : ''} -
${acts}
-
`; - }).join(''); + $('vv-ai-queue').innerHTML = shown.map(r => + r.src === 'repair' ? qFindingHtml(r) : qMemoryHtml(r)).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; + // 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'; + } - const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`); + function qPill(key, label, n) { + const on = qFilter === key; + return ``; + } - // 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') { + function qFindingHtml(row) { + const f = row.raw; + const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last), + 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 + // page is spelled "do not click anything". + const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a => + `` + ).join(''); + + return `
+
+ repair + ${esc(f.subject)} + ${esc(f.kind)} + ${esc(meta)} +
+
${esc(f.ref)}
+
${esc(f.evidence)}
+ ${f.proposed !== null && f.proposed !== undefined + ? `
${esc(f.conf_file)} · ${esc(f.conf_key)} ` + + `${esc(f.observed || '(empty)')} ${esc(f.proposed)}` + + (f.proven ? '' : ' · unproven') + `
` + : ''} + ${f.note ? `
${esc(f.note)}
` : ''} +
${acts}
+
`; + } + + 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 ? `
asked: ${esc(r.asked)}
` : ''; + // 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 ? '' : + `` + + ``; + + return `
+
+ memory + ${esc(meta)} +
+
${esc(r.text)}
+ ${asked} +
${acts}
+
`; + } + + // 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' - ? `Writes the change above to ${esc(f.conf_file)}` - + `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.` - : `Stays closed even when it is seen again. ` - + `Use “I know” to be told if it changes. Click again to confirm.`; + if (msg) msg.innerHTML = `${qArmWarning(row, act)}`; // 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))); - }); - - $('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(' · ')) - : `${esc(gate.join(' · '))}`; - - const openN = (d.open || []).length; - $('vv-ai-mem-sum').innerHTML = openN - ? `${openN} to review` - : `✓ none waiting`; - - if (!memRows.length) { - $('vv-ai-mem-list').innerHTML = memAll - ? '
nothing proposed yet
' - : '
nothing waiting on you
'; - return; + // 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.`; } - - $('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 ? `
asked: ${esc(r.asked)}
` : ''; - const acts = decided ? '' : - `` - + ``; - - return `
-
${esc(meta)}
-
${esc(r.text)}
- ${asked} -
${acts}
-
`; - }).join(''); + 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-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 = `${esc(text)}`; - }; - - 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-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-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(); })();