Put the AI toggles and findings on the pages they act on

The five AI_ASSIST_ switches and the findings they produce were reachable only
from the AI tab, which is a long way from the page a finding is about.

Nothing was synchronised because nothing needed to be: findings are one file per
finding, and every surface is a view over that store with actions going to the
same endpoint. Acting on the Watchdog tab shows on the AI tab because they are
not two copies. The strip reloads rather than editing its own row, which is the
only way they could have drifted apart.

Each strip shows one page's kinds. Actions are whatever the server offers for
that row, so Move appears on media findings without this card knowing what a
move is.
This commit is contained in:
Gmer4Lfe
2026-08-14 22:32:55 -04:00
parent a705aa36b7
commit 90774f1442
3 changed files with 96 additions and 0 deletions
+79
View File
@@ -64,6 +64,85 @@
// fatals on the one page that included this without it, which is what took the AI tab blank.
require_once __DIR__ . '/confform.php';
// ── A findings strip for one subject page ─────────────────────────────────────────────────────
// The AI tab owns the full findings card — every kind, every action, the repair banner. This is
// the same store seen through a keyhole: one page's kinds, on the page that page is about.
//
// There is no synchronisation to build and none was built. Findings are one file per finding under
// AI_DATA_DIR; every surface is a view over that store and every action goes to the same endpoint,
// so acting on the Watchdog tab is visible on the AI tab because they are not two copies. The only
// thing that could desynchronise them is caching the list, which is why this does not.
//
// Gated on vv_ai_ui_on() like every other AI surface: on a node without the model there is nothing
// producing findings, and a permanently empty card is a worse answer than no card.
function vv_ai_findings_strip(string $prefix, array $kinds, string $title): void {
if (!vv_ai_ui_on()) return;
$k = json_encode(array_values($kinds), JSON_UNESCAPED_SLASHES);
?>
<div class="vv-card" id="<?= htmlspecialchars($prefix) ?>-card" style="margin-top:12px;display:none;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">
<?= htmlspecialchars($title) ?></span>
<span id="<?= htmlspecialchars($prefix) ?>-n" style="font-size:10px;color:#3a3a3a;"></span>
<a href="/Varaverk?tab=ai" style="margin-left:auto;font-size:10px;color:#4a6a8a;text-decoration:none;">
full list on the AI tab →</a>
</div>
<div id="<?= htmlspecialchars($prefix) ?>-rows"></div>
</div>
<script>
(function () {
const PFX = <?= json_encode($prefix) ?>, KINDS = <?= $k ?>;
const card = document.getElementById(PFX + '-card');
const rows = document.getElementById(PFX + '-rows');
if (!card || !rows) return;
function load() {
fetch('/plugins/varaverk/api/ai.php?action=findings')
.then(r => r.json())
.then(d => {
const all = (d && d.findings) || [];
const mine = all.filter(f => KINDS.includes(f.kind));
if (!mine.length) { card.style.display = 'none'; return; }
card.style.display = '';
document.getElementById(PFX + '-n').textContent = mine.length;
rows.innerHTML = mine.map(f => {
const sev = f.severity || f.sys_level || '';
const col = sev === 'error' ? '#ef5350' : sev === 'warn' ? '#ffb74d' : '#666';
// Actions are the server's answer, not this card's guess — the same list the AI tab
// renders. Pressing here and pressing there are the same call on the same record.
const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
`<button class="vv-btn-sm" data-fid="${vvEscAttr(f.id)}" data-act="${vvEscAttr(a)}"
title="${vvEscAttr(f.actions[a])}">${vvEscHtml(a)}</button>`).join(' ');
return `<div style="border-left:2px solid ${col};padding:5px 0 5px 8px;margin-bottom:6px;">
<div style="font-size:11px;color:#bbb;">${vvEscHtml(f.subject || '?')}
<span style="color:#3a3a3a;font-size:9px;margin-left:5px;">${vvEscHtml(f.observed || '')}</span></div>
<div style="font-size:10px;color:#555;line-height:1.5;margin:2px 0 4px;">${vvEscHtml(f.evidence || '')}</div>
<div style="display:flex;gap:4px;">${acts}</div>
</div>`;
}).join('');
})
.catch(() => {});
}
// Delegated, and it reloads rather than mutating the row: the store is the truth and a card that
// edited its own copy would be the one place these could disagree.
rows.addEventListener('click', ev => {
const b = ev.target.closest('[data-fid]');
if (!b) return;
b.disabled = true;
fetch('/plugins/varaverk/api/ai.php', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'finding_action', id: b.dataset.fid, act: b.dataset.act }) })
.then(r => r.json()).then(() => load()).catch(() => { b.disabled = false; });
});
load();
setInterval(load, 60000);
})();
</script>
<?php
}
// A whole settings card: disclosure, filter, save, and the fields for one subject. Emitted by a
// page in one line rather than assembled there.
//