diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index e6e84f2..302eb16 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -65,6 +65,8 @@ // GET ?action=memory_get the operator memory file and its budget // GET ?action=chats stored conversations, newest first, metadata only // GET ?action=chat_get&id= one stored conversation with its transcript +// GET ?action=findings [all=1] repair findings, open only unless all=1 +// POST action=finding_action id= act=fix|ack|dismiss|reopen|cancel [note=…] // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=memory_set memory=… replace the memory file // POST action=clear token= discard a finished job @@ -77,11 +79,14 @@ // poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}} // clear {"ok":true} // chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N} +// findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}} +// finding_action {"ok":true,"action":"fix"} // chat_save {"ok":true,"id":"","title":…} // {"ok":false,"error":…} // // DEPENDS ON // include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*() +// include/ai_repair.php the findings store — loaded only by the two actions that read it // Tools/ai_chat_worker.php the detached worker // ═══════════════════════════════════════════════════════════════════════════════════════════════ // First executable statement, deliberately dependency-free. A request that is rejected by the @@ -290,6 +295,59 @@ if ($action === 'bug_close') { exit; } +// ── findings / finding_action ───────────────────────────────────────────────── +// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled +// in here rather than at the top of the file: it is the largest include in the plugin and poll +// runs once a second per open tab, so it is loaded by the two actions that need it and by nothing +// else. +// +// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being +// true when it goes off, and answering them — including saying "this was never a problem" — is +// exactly what an operator turning the feature off is likely to want to do first. The gate states +// are reported instead, so the card can say what is running rather than the endpoint pretending +// the store is empty. +if ($action === 'findings' || $action === 'finding_action') { + require_once dirname(__DIR__) . '/include/ai_repair.php'; + + if ($action === 'findings') { + // Closed findings are the history — what was dismissed, what a fix actually fixed — and + // they are asked for explicitly rather than shipped with every poll of the open list. + $rows = []; + $open = 0; $needs = 0; + foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) { + $state = (string)($f['state'] ?? 'open'); + if ($state === 'open') $open++; + elseif ($state === 'needs_operator') $needs++; + // The three things the page must not decide for itself: which actions this row + // offers, and what its state and kind mean in words. + $f['actions'] = vv_ai_finding_actions($f); + $f['state_label'] = VV_AI_FINDING_STATES[$state] ?? ''; + $f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? ''; + $rows[] = $f; + } + echo json_encode(['ok' => true, + 'repair' => ['enabled' => vv_ai_repair_enabled(), + 'autofix' => vv_ai_repair_autofix_enabled(), + 'last' => vv_ai_sweep_last()], + 'findings' => $rows, + 'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]); + exit; + } + + // POST, because fix writes conf through the guarded path and every other answer writes state. + // Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this + // endpoint's — a tab left open overnight is holding buttons the store has moved past. + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + + $fid = trim($_POST['id'] ?? ''); + $act = trim($_POST['act'] ?? ''); + $r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? '')); + vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act, + $r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?'))); + echo json_encode($r); + exit; +} + // ── incident_add ────────────────────────────────────────────────────────────── // Appends one operator-written "this was the fix" note against a scope. POST only, and the // scope is whitelisted the same way ask's is — it is written to a file that later rides in a diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index b378b2f..75796e2 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -47,17 +47,27 @@ // 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. +// The conversation is read-only with respect to the system. The findings card is not. +// Asking questions changes nothing, and every profile reachable from the composer holds +// zero capabilities. The one control on this page that changes Varaverk state is Fix on a +// repair finding, which writes a single conf key through vv_conf_edit()'s guarded path — +// lock, backup, syntax check, read-back, rollback. It is confirmed first, it names the +// file, key and both values before it is clicked, and it is the only way a toggle is ever +// written by this subsystem, because reaching it means the operator asked for it by name. +// +// The page never decides what a finding may do. +// Buttons are rendered from the actions api/ai.php returned for that row, and the endpoint +// checks the same list again before acting. A tab left open overnight holds buttons the +// store has moved past, so the row the operator sees is not the authority on what is legal. // // 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 +// Status banner (index, model residency, GPU, staleness), repair findings with their actions, +// assistant-filed bug reports, chat transcript with collapsible reasoning and audited sources, +// composer with retrieval-scope and reasoning controls, source viewer overlay // // DEPENDS ON // 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/ai.php stats / tokens / bugs / findings / finding_action / ask / poll / chats // api/readscript.php source viewer contents // ═══════════════════════════════════════════════════════════════════════════════════════════════ require_once dirname(__DIR__) . '/include/ai_chat.php'; @@ -149,6 +159,37 @@ 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 + 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. */ +.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; } +.vv-ai-fnd.sev-warn { border-left-color:#6a5228; background:#130f0a; } +.vv-ai-fnd.closed { opacity:.55; } +.vv-ai-fnd-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; flex-wrap:wrap; } +.vv-ai-fnd-s { font-family:monospace; font-size:11px; color:#c8c8c8; } +.vv-ai-fnd-k { font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:#4a4a4a; + border:1px solid #262626; border-radius:3px; padding:1px 5px; } +.vv-ai-fnd-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; } +.vv-ai-fnd-r { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; } +.vv-ai-fnd-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e; + border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a; + white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; } +/* The proposed write, spelled out in full before anything is clicked. This is the one line that + says what Fix will actually do to conf, so it is not abbreviated and not hidden. */ +.vv-ai-fnd-w { font-size:11px; font-family:monospace; color:#8a8a8a; margin-top:6px; + padding:5px 8px; background:#0b0b0b; border:1px solid #1e1e1e; border-radius:3px; } +.vv-ai-fnd-w b { color:#c8c8c8; font-weight:normal; } +.vv-ai-fnd-w .arrow { color:#4a4a4a; } +.vv-ai-fnd-n { font-size:10px; color:#5a5a5a; margin-top:5px; font-style:italic; } +.vv-ai-fnd-a { display:flex; gap:7px; margin-top:7px; align-items:center; flex-wrap:wrap; } +/* Each button explains itself on hover from the server's own text, so the page never has to + restate what an action means and cannot restate it differently. */ +.vv-ai-fnd-a .vv-ai-btn { padding:3px 11px; font-size:11px; } +.vv-ai-fnd-msg { font-size:10px; color:#5a5a5a; margin-left:4px; } + /* ── 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. @@ -187,6 +228,25 @@ if (is_dir('/var/log/varaverk')) { + +
+
+
+ Self-repair findings + + +
+
+
+
+ @@ -527,6 +587,133 @@ 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 = []; + + // 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' }; + + function loadFindings() { + fetch(API + '?action=findings' + (fndAll ? '&all=1' : '')).then(r => r.json()) + .then(d => { if (d.ok) renderFindings(d); }) + .catch(() => {}); + } + + function renderFindings(d) { + fndRows = d.findings || []; + const rep = d.repair || {}, c = d.counts || {}; + + // 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(' · '))}`; + + // 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`; + + if (!fndRows.length) { + $('vv-ai-fnd').innerHTML = fndAll + ? '
nothing filed yet
' + : '
nothing open — the last sweep found no new faults
'; + 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(''); + } + + // 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; + + // The two that cannot be walked back get asked about first. Fix edits a conf file, and + // dismiss is the one state the sweep will never reopen on its own however many times the + // fault comes back. + if (act === 'fix' && !confirm( + `Write ${f.conf_key} = ${f.proposed}\n\nin ${f.conf_file}, replacing ${f.observed || '(empty)'}.` + + (f.proven ? '' : '\n\nNothing has probed this value — it is a proposal, not a proven fix.'))) + return; + if (act === 'dismiss' && !confirm( + `Dismiss "${f.subject} — ${f.ref}" permanently?\n\nIt stays closed even when it is seen ` + + `again. To be told if it changes, use "I know" instead.`)) return; + + const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`); + const btns = Array.from(btn.parentNode.querySelectorAll('button')); + 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, and the operator decides what to do next. + const failed = text => { + if (msg) msg.innerHTML = `${esc(text)}`; + btns.forEach(b => b.disabled = false); + }; + + fetch(API, { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ action: 'finding_action', id, act }) }) + .then(r => r.json()) + .then(d => { if (d.ok) loadFindings(); 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(); + }); + // ── Token accounting ──────────────────────────────────────────────────── // Fetched on load and after each completed turn, never on the 30s banner tick: the totals // only move when a turn finishes, and the page is the thing that knows when that was. @@ -699,11 +886,16 @@ vv_ai_chat_markup('vv-ai', [ // 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); }; + // 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); + window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); }; loadBanner(); loadTokens(); loadBugs(); + loadFindings(); chatList.reload(); })();