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.
//
+9
View File
@@ -155,6 +155,15 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
// constant is what tells the assistant where to send someone, and this is what the page actually
// draws. The two disagreeing means being given directions to a card that is not there.
vv_conf_ui_card('vv-cf-arrs', 'arr|sonarr|radarr|lidarr|media|play state|emby|jellyfin|plex', 'Media settings');
// Same card as the Watchdog and AI tabs carry — one conf value, three views. AI_ASSIST_DISCOVERY
// is the switch that decides whether the classification triage runs at all, and this is the page
// it produces findings about.
vv_conf_ui_card('vv-cf-ms-ai', 'AI Feature Toggles', 'AI features');
// Misfiled media, with the Move action attached. Same store the AI tab reads, so a series moved
// from either surface is gone from both.
vv_ai_findings_strip('vv-ms-fnd', ['media_misfiled'], 'Media findings');
?>
<script>
(function() {
+8
View File
@@ -253,6 +253,14 @@ $_vv_wd_installed = vv_wd_installed_containers();
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-watchdog', 'watchdog', 'Watchdog settings');
// The same AI switches the AI tab carries, on the page they act on. One conf value behind both,
// written through the same guarded path, so the two cards cannot disagree — a toggle flipped here
// is flipped there because there is only one of it.
vv_conf_ui_card('vv-cf-wd-ai', 'AI Feature Toggles', 'AI features');
// Findings this page's watchdogs produced, actionable here rather than only on the AI tab.
vv_ai_findings_strip('vv-wd-fnd', ['watchdog_strike'], 'Watchdog findings');
?>
<script>
(function() {