Give every AI setting a control, and every page one set to draw it with

The AI tab could show three per-turn checkboxes and no configuration at all;
the forty-three keys behind it were editable only by hand.
This commit is contained in:
Gmer4Lfe
2026-08-11 19:03:12 -04:00
parent f90b23ddd9
commit 2b933c45dd
3 changed files with 441 additions and 2 deletions
+114 -2
View File
@@ -71,6 +71,7 @@
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_chat.php';
require_once dirname(__DIR__) . '/include/confui.php';
// Build stamp. The tab bar uses Unraid's localURL, which swaps content by AJAX without tearing
// down the previous page's JavaScript — so a stale copy of this script can keep running, and
@@ -246,9 +247,20 @@ if (is_dir('/var/log/varaverk')) {
border-top:1px solid #1a1a1a; }
.vv-ai-set-r:first-child { border-top:none; }
.vv-ai-set-l { font-size:11px; color:#8a8a8a; min-width:120px; flex-shrink:0; }
/* The line between "this conversation" and "this installation". Heavier than the row separators
above it, because crossing it is the difference between a checkbox and a conf write. */
.vv-ai-conf-wrap { margin-top:12px; border-top:1px solid #2a2a2a; padding-top:10px; }
.vv-ai-conf-h { display:flex; align-items:center; gap:9px; font-size:9px; letter-spacing:.08em;
text-transform:uppercase; color:#6a6a6a; margin-bottom:9px; }
.vv-ai-conf-sub { text-transform:none; letter-spacing:0; font-size:10px; color:#4a4a4a;
font-family:monospace; }
.vv-ai-conf-h .vv-ai-btn { margin-left:auto; padding:3px 12px; font-size:11px; }
/* Scrolls on its own rather than growing the card past the viewport — forty-three fields is
taller than the screen, and the Save button belongs where it can still be reached. */
#vv-ai-conf { max-height:52vh; overflow-y:auto; padding-right:4px; }
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
</style>
<?php vv_ai_chat_assets(); ?>
<?php vv_ai_chat_assets(); vv_conf_ui_assets(); ?>
<div id="vv-ai-wrap">
@@ -404,6 +416,24 @@ vv_ai_chat_markup('vv-ai', [
with the question — seconds for a greeting, ~18s for a substantive one.</div>
</div>
</div>
<!-- Everything above shapes the next question and is forgotten after it. Everything below
is written to master.conf and outlives the tab. One card, because "where do I change
the AI" has one answer — but a hard divider and its own heading, because a control that
edits a file every script sources should never be mistaken for a per-turn checkbox.
Loaded when the card is first opened rather than on page load: forty-three fields off
two conf files is real work, and this card is shut the overwhelming majority of the
time it exists. -->
<div class="vv-ai-conf-wrap">
<div class="vv-ai-conf-h">
Configuration
<span class="vv-ai-conf-sub">written to master.conf</span>
<span class="vv-ai-set-sum" id="vv-ai-conf-sum"></span>
<button class="vv-ai-btn" id="vv-ai-conf-save" type="button" disabled>Save</button>
</div>
<div id="vv-ai-conf"><div class="vv-ai-none">opens when you do</div></div>
</div>
</div>
</div>
</div>
@@ -427,6 +457,10 @@ vv_ai_chat_markup('vv-ai', [
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// The conf form's own endpoint, not this page's. Settings are written by the same guarded path
// the Scheduler uses, so there is one allowlist and one write path rather than an AI-shaped
// copy of both — api/ai.php deliberately has no conf-writing action at all.
const API_CONF = '/plugins/varaverk/api/confform.php';
// The conversation itself — profiles, transcript, composer, source viewer, storage — is
// include/ai_chat.php. What remains on this page is everything that surrounds it and exists
@@ -1048,7 +1082,85 @@ vv_ai_chat_markup('vv-ai', [
}
$('vv-ai-set-t').addEventListener('click', () => {
$('vv-ai-set-t').classList.toggle('open');
$('vv-ai-set-b').classList.toggle('open');
const open = $('vv-ai-set-b').classList.toggle('open');
// First open only. Re-reading on every toggle would discard edits in progress the moment
// someone collapsed the card to look at something else.
if (open) loadConf();
});
// ── AI configuration ────────────────────────────────────────────────────
// The conf sections whose header names AI, drawn by the shared renderer and written back
// through the same endpoint the Scheduler's per-script form uses. Nothing about which fields
// exist, what they are called or what control each gets is decided here — all of it is read
// from master.conf, so a key added tomorrow appears with no change to this page.
let confLoaded = false;
function loadConf() {
if (confLoaded) return;
confLoaded = true;
fetch(API_CONF + '?sections=ai').then(r => r.json())
.then(d => {
if (!d.ok) { $('vv-ai-conf').innerHTML = `<div class="vv-ai-none">${esc(d.error || 'could not be read')}</div>`; return; }
VvConfUI.render('vv-ai-conf', d.groups || []);
VvConfUI.wire('vv-ai-conf', confDirty);
confDirty();
})
.catch(e => {
// Retryable: the flag goes back so reopening the card tries again rather than leaving a
// permanent error where the settings should be.
confLoaded = false;
$('vv-ai-conf').innerHTML = `<div class="vv-ai-none">could not be read: ${esc(String(e))}</div>`;
});
}
function confDirty() {
const n = VvConfUI.dirtyCount('vv-ai-conf');
$('vv-ai-conf-save').disabled = n === 0;
$('vv-ai-conf-sum').innerHTML = n
? `<span class="vv-ai-warn">${n} unsaved</span>`
: '';
}
$('vv-ai-conf-save').addEventListener('click', () => {
const changes = VvConfUI.collect('vv-ai-conf');
if (!changes.length) return;
const btn = $('vv-ai-conf-save');
btn.disabled = true;
$('vv-ai-conf-sum').innerHTML = 'saving…';
// URLSearchParams, not FormData. A multipart POST to this plugin's endpoints hangs with no
// status ever returned — the request leaves the browser and never reaches PHP.
fetch(API_CONF, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
.then(r => r.text())
.then(t => {
let d;
try { d = JSON.parse(t); }
catch (_) {
// The CSRF prepend terminates with an empty body, so this is the shape that failure
// takes. Naming it beats a bare parse error.
throw new Error(t.trim() ? 'unparseable response: ' + t.slice(0, 120)
: 'empty response — the request was rejected before the endpoint ran');
}
if (!d.ok) throw new Error(d.error || 'save failed');
// Rebased rather than refetched: a reload would repaint every control including the one
// just edited, and the values on screen are now the values on disk.
VvConfUI.commit('vv-ai-conf');
confDirty();
$('vv-ai-conf-sum').innerHTML =
`<span class="vv-ai-ok">saved ${changes.length} change${changes.length > 1 ? 's' : ''}</span>`;
// The banner reads AI_ENABLED and the rest of the gates, so it is now stale.
loadBanner();
loadQueue();
})
.catch(e => {
// Left dirty on purpose. A refused write changed nothing, and clearing the marks would
// say it had — see the same rule on the findings card.
$('vv-ai-conf-sum').innerHTML = `<span class="vv-ai-bad">${esc(e.message || String(e))}</span>`;
confDirty();
});
});
$('vv-ai-kind').addEventListener('change', setSummary);
$('vv-ai-think').addEventListener('change', setSummary);