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:
@@ -513,6 +513,58 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
|
|||||||
.vv-cf-none { color: #9a8f6a; font-size: 12px; margin: 0; padding: 8px 10px;
|
.vv-cf-none { color: #9a8f6a; font-size: 12px; margin: 0; padding: 8px 10px;
|
||||||
background: #1c1a12; border: 1px solid #33301f; border-left: 3px solid #7a6a3a;
|
background: #1c1a12; border: 1px solid #33301f; border-left: 3px solid #7a6a3a;
|
||||||
border-radius: 3px; }
|
border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── Typed settings controls ──────────────────────────────────────────────────
|
||||||
|
The controls the conf's own comments ask for: a switch for a boolean, a bounded number for a
|
||||||
|
number, a list for a documented set of choices. Everything above renders a conf field as a
|
||||||
|
text box, which is the honest default for a file of shell assignments and the wrong one for
|
||||||
|
the seventy-two of them that hold true or false.
|
||||||
|
|
||||||
|
Extensions of vv-cf-*, not a new family. The nine page-private settings vocabularies in this
|
||||||
|
plugin exist because each page needed one control the shared set did not have and started a
|
||||||
|
set of its own; adding the control here is what stops the tenth.
|
||||||
|
|
||||||
|
Note vv-cf-num is already taken — it is the row-number gutter, not a number input. */
|
||||||
|
.vv-cf-ctl { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.vv-cf-ctl .vv-cf-scalar { flex: 1 1 auto; min-width: 0; }
|
||||||
|
/* Bounded, so it does not stretch to the width of a path field and invite a paragraph. */
|
||||||
|
.vv-cf-int { max-width: 150px; flex: 0 0 auto !important; }
|
||||||
|
.vv-cf-unit { font-size: 11px; color: #5a5a5a; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Sliding switch, lifted from the settings page — the nicest of the six this plugin had grown,
|
||||||
|
and the only one that reads as on or off from across a room rather than up close. */
|
||||||
|
.vv-cf-toggle { display: inline-flex; align-items: center; gap: 9px; cursor: pointer;
|
||||||
|
user-select: none; }
|
||||||
|
.vv-cf-track { width: 32px; height: 18px; border-radius: 9px; background: #222; border: 1px solid #333;
|
||||||
|
position: relative; transition: background .15s, border-color .15s; flex-shrink: 0; }
|
||||||
|
.vv-cf-track.on { background: #1a3a1a; border-color: #2d5a2d; }
|
||||||
|
.vv-cf-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
|
||||||
|
border-radius: 50%; background: #555; transition: left .15s, background .15s; }
|
||||||
|
.vv-cf-track.on::after { left: 16px; background: #4caf50; }
|
||||||
|
/* States the value, not the setting's name — the key is already printed above it. Without this a
|
||||||
|
switch mid-travel is the only thing saying which way it is pointing. */
|
||||||
|
.vv-cf-state { font-size: 11px; color: #5a5a5a; font-family: monospace; }
|
||||||
|
|
||||||
|
.vv-cf-sel { background: #111; border: 1px solid #333; color: #ddd; padding: 4px 8px;
|
||||||
|
border-radius: 4px; font-family: monospace; font-size: 12px; max-width: 280px; }
|
||||||
|
.vv-cf-sel:focus { border-color: #555; outline: none; }
|
||||||
|
/* What the selected choice means, taken from the comment line that declared it. The confs
|
||||||
|
already explain each option; repeating that in the page would be a second copy to drift. */
|
||||||
|
.vv-cf-opthint { font-size: 10px; color: #5a5a5a; font-style: italic; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* A credential is editable and not readable. Masked rather than omitted, because a settings page
|
||||||
|
that silently drops the fields it disapproves of is one you cannot trust to be complete. */
|
||||||
|
.vv-cf-secret { letter-spacing: .12em; }
|
||||||
|
.vv-cf-reveal { background: none; border: 1px solid #2e2e2e; color: #5a5a5a; font-size: 10px;
|
||||||
|
padding: 3px 8px; border-radius: 3px; cursor: pointer; flex-shrink: 0; }
|
||||||
|
.vv-cf-reveal:hover { color: #aaa; border-color: #4a4a4a; }
|
||||||
|
|
||||||
|
/* Touched but not yet saved. The row says so itself rather than relying on a Save button
|
||||||
|
somewhere else having become enabled. */
|
||||||
|
.vv-cf-field.dirty { border-left-color: #6a5228; background: #14110a; }
|
||||||
|
.vv-cf-dot { width: 5px; height: 5px; border-radius: 50%; background: #ffb74d; flex-shrink: 0;
|
||||||
|
display: none; }
|
||||||
|
.vv-cf-field.dirty .vv-cf-dot { display: block; }
|
||||||
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
|
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
|
||||||
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
|
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
|
||||||
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
|
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PURPOSE
|
||||||
|
// Draws a parsed conf field as the control it deserves, and reads the edits back out. One
|
||||||
|
// implementation for every page that edits settings, so a switch looks and behaves the same
|
||||||
|
// wherever it is met.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS
|
||||||
|
// Ten pages edited settings and nine had written their own vocabulary to do it — nine sets of
|
||||||
|
// inline CSS, six different toggle switches, and two pages that had both claimed the vv-set-*
|
||||||
|
// prefix with disjoint class names, so it read as shared and was not. Unraid swaps tabs by AJAX
|
||||||
|
// without unloading, so two of those stylesheets can be live in one document at once.
|
||||||
|
//
|
||||||
|
// Each of them started for the same reason: the shared set could render a conf field as a text
|
||||||
|
// box and nothing else, and every page eventually needed one control it did not have. The fix
|
||||||
|
// is not a convention that asks people not to do that again — it is having the control here.
|
||||||
|
//
|
||||||
|
// WHAT DECIDES A CONTROL
|
||||||
|
// Not this file. confform.php infers a widget from the conf itself — the value's shape and the
|
||||||
|
// comments already written above it — and this renders whatever it was told. That split is
|
||||||
|
// deliberate: the schema lives in the conf, the appearance lives here, and neither can quietly
|
||||||
|
// start deciding the other.
|
||||||
|
//
|
||||||
|
// DESIGN PRINCIPLES
|
||||||
|
// The control is a view of the value, never a second copy of it.
|
||||||
|
// Every control carries data-key, data-file, data-type and data-orig, and the payload is
|
||||||
|
// rebuilt by reading the DOM at save time. There is no parallel model to fall out of step
|
||||||
|
// with what is on screen.
|
||||||
|
//
|
||||||
|
// Only what changed is sent.
|
||||||
|
// A field whose value still equals data-orig is omitted. A settings page that submits all
|
||||||
|
// four hundred fields rewrites four hundred lines to change one, and every one of those
|
||||||
|
// rewrites is a chance to lose a comment.
|
||||||
|
//
|
||||||
|
// A value is returned exactly as it was typed.
|
||||||
|
// No trimming beyond the edges, no case folding, no normalising true to 1. The conf is
|
||||||
|
// shell source and the consuming script decides what it means.
|
||||||
|
//
|
||||||
|
// OPERATIONAL SAFEGUARDS
|
||||||
|
// Everything is escaped on the way in.
|
||||||
|
// Values come from conf files that hold paths, globs and quotes. Rendering goes through
|
||||||
|
// one escape helper and attributes through another — vvEscHtml does not escape quotes, so
|
||||||
|
// an attribute needs vvEscAttr or a path containing one closes it and the rest executes.
|
||||||
|
//
|
||||||
|
// Secrets render masked and are never pre-filled from a stale read.
|
||||||
|
// A masked field left untouched sends nothing, so a save cannot round-trip a credential
|
||||||
|
// back through the browser.
|
||||||
|
//
|
||||||
|
// Nothing here writes. The payload goes to api/confform.php, which owns the allowlist, and
|
||||||
|
// from there to vv_conf_edit(), which owns the lock, the backup and the rollback.
|
||||||
|
//
|
||||||
|
// EXPORTS
|
||||||
|
// vv_conf_ui_assets() the renderer, emitted once per page
|
||||||
|
//
|
||||||
|
// DEPENDS ON
|
||||||
|
// include/confform.php the widget each field was inferred to want
|
||||||
|
// api/confform.php ?sections= to read, POST changes to write
|
||||||
|
// css/varaverk.css the vv-cf-* family these classes belong to
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function vv_conf_ui_assets(): void {
|
||||||
|
static $done = false;
|
||||||
|
if ($done) return;
|
||||||
|
$done = true;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (window.VvConfUI) return;
|
||||||
|
|
||||||
|
// The page's own escape helpers where they exist. They are global in Varaverk.page for exactly
|
||||||
|
// this reason, and a second private copy here is a second thing to fix when one of them is
|
||||||
|
// found to be wrong.
|
||||||
|
const esc = s => (window.vvEscHtml ? vvEscHtml(s) : String(s == null ? '' : s)
|
||||||
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'));
|
||||||
|
const escA = s => (window.vvEscAttr ? vvEscAttr(s) : String(s == null ? '' : s)
|
||||||
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
|
.replace(/"/g,'"').replace(/'/g,'''));
|
||||||
|
|
||||||
|
// Shared by every control. data-orig is what "changed" is measured against, and it is the
|
||||||
|
// parsed value rather than anything the page derived — so a control that renders a value
|
||||||
|
// differently than it stores it still compares against the truth.
|
||||||
|
function attrs(f) {
|
||||||
|
return ` data-key="${escA(f.key)}" data-file="${escA(f.file)}"`
|
||||||
|
+ ` data-type="${escA(f.type || 'scalar')}" data-orig="${escA(f.value == null ? '' : f.value)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolCtl(f) {
|
||||||
|
const on = String(f.value).toLowerCase() === 'true';
|
||||||
|
return `<div class="vv-cf-ctl">
|
||||||
|
<label class="vv-cf-toggle">
|
||||||
|
<span class="vv-cf-track${on ? ' on' : ''}"></span>
|
||||||
|
<input type="checkbox" hidden${attrs(f)} data-ctl="bool"${on ? ' checked' : ''}>
|
||||||
|
<span class="vv-cf-state">${on ? 'true' : 'false'}</span>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intCtl(f) {
|
||||||
|
const min = (f.min !== undefined && f.min !== null) ? ` min="${escA(f.min)}"` : '';
|
||||||
|
const max = (f.max !== undefined && f.max !== null) ? ` max="${escA(f.max)}"` : '';
|
||||||
|
const unit = f.unit ? `<span class="vv-cf-unit">${esc(f.unit)}</span>` : '';
|
||||||
|
return `<div class="vv-cf-ctl">
|
||||||
|
<input type="number" class="vv-cf-scalar vv-cf-int"${min}${max}
|
||||||
|
value="${escA(f.value)}"${attrs(f)} data-ctl="int">${unit}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumCtl(f) {
|
||||||
|
const cur = String(f.value);
|
||||||
|
const opts = (f.choices || []).map(c =>
|
||||||
|
`<option value="${escA(c.value)}"${c.value === cur ? ' selected' : ''}>${esc(c.value)}</option>`
|
||||||
|
).join('');
|
||||||
|
// The hint belongs to the selected choice, so it is rebuilt on change rather than listing
|
||||||
|
// every option's explanation at once — which on a five-way choice is a paragraph.
|
||||||
|
const hints = {};
|
||||||
|
(f.choices || []).forEach(c => { if (c.hint) hints[c.value] = c.hint; });
|
||||||
|
const curHint = hints[cur] || '';
|
||||||
|
return `<div class="vv-cf-ctl">
|
||||||
|
<select class="vv-cf-sel"${attrs(f)} data-ctl="enum"
|
||||||
|
data-hints="${escA(JSON.stringify(hints))}">${opts}</select>
|
||||||
|
</div><div class="vv-cf-opthint">${esc(curHint)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretCtl(f) {
|
||||||
|
// Rendered from the real value so the field is genuinely editable, but typed as a password so
|
||||||
|
// it is not read over a shoulder or captured in a screenshot of the tab.
|
||||||
|
return `<div class="vv-cf-ctl">
|
||||||
|
<input type="password" class="vv-cf-scalar vv-cf-secret" autocomplete="new-password"
|
||||||
|
value="${escA(f.value)}"${attrs(f)} data-ctl="text">
|
||||||
|
<button type="button" class="vv-cf-reveal" data-reveal>Show</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesCtl(f) {
|
||||||
|
const v = String(f.value == null ? '' : f.value);
|
||||||
|
const n = v.split('\n').length;
|
||||||
|
const gutter = Array.from({ length: n }, (_, i) => i + 1).join('\n');
|
||||||
|
return `<div class="vv-cf-arraywrap">
|
||||||
|
<pre class="vv-cf-lines">${gutter}</pre>
|
||||||
|
<textarea class="vv-cf-array" rows="${Math.min(Math.max(n, 3), 18)}"${attrs(f)}
|
||||||
|
data-ctl="text" spellcheck="false">${esc(v)}</textarea>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textCtl(f) {
|
||||||
|
return `<div class="vv-cf-ctl">
|
||||||
|
<input type="text" class="vv-cf-scalar" value="${escA(f.value)}"${attrs(f)}
|
||||||
|
data-ctl="text" spellcheck="false">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTROLS = { bool: boolCtl, int: intCtl, enum: enumCtl, secret: secretCtl,
|
||||||
|
lines: linesCtl, path: textCtl, text: textCtl };
|
||||||
|
|
||||||
|
function fieldHtml(f) {
|
||||||
|
const draw = CONTROLS[f.widget] || textCtl;
|
||||||
|
// The description is the conf's own comment. It is the only documentation most of these
|
||||||
|
// settings have, and it is why the form is readable at all.
|
||||||
|
const desc = f.desc ? `<div class="vv-cf-desc">${esc(f.desc)}</div>` : '';
|
||||||
|
return `<div class="vv-cf-field" data-field="${escA(f.key)}">
|
||||||
|
<span class="vv-cf-dot"></span>
|
||||||
|
<div class="vv-cf-body">
|
||||||
|
<div class="vv-cf-key">${esc(f.key)}</div>
|
||||||
|
${desc}
|
||||||
|
${draw(f)}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupHtml(g) {
|
||||||
|
const fields = (g.fields || []).map(fieldHtml).join('');
|
||||||
|
if (!fields) return '';
|
||||||
|
return `<div class="vv-cf-group">
|
||||||
|
<div class="vv-cf-group-header">${esc(g.subsection || g.name || '')}
|
||||||
|
<span class="vv-cf-file">${esc(g.file || '')}</span>
|
||||||
|
</div>
|
||||||
|
${fields}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading a control back. Kept beside the drawing so a new control cannot be added without a
|
||||||
|
// matching way to read it — the two halves drifting is how a field silently stops saving.
|
||||||
|
function readCtl(el) {
|
||||||
|
if (el.dataset.ctl === 'bool') return el.checked ? 'true' : 'false';
|
||||||
|
return el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VvConfUI = {
|
||||||
|
render(into, groups) {
|
||||||
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
||||||
|
if (!box) return;
|
||||||
|
const html = (groups || []).map(groupHtml).join('');
|
||||||
|
box.innerHTML = html || '<p class="vv-cf-empty">Nothing configurable here.</p>';
|
||||||
|
return box;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Only what actually differs from what was parsed. See the note on partial saves above.
|
||||||
|
collect(into) {
|
||||||
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
||||||
|
if (!box) return [];
|
||||||
|
const out = [];
|
||||||
|
box.querySelectorAll('[data-ctl]').forEach(el => {
|
||||||
|
const now = readCtl(el);
|
||||||
|
if (now === el.dataset.orig) return;
|
||||||
|
out.push({ file: el.dataset.file, key: el.dataset.key,
|
||||||
|
type: el.dataset.type || 'scalar', value: now });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
dirtyCount(into) { return this.collect(into).length; },
|
||||||
|
|
||||||
|
// Called after a successful save so the next collect() measures against what is now on disk,
|
||||||
|
// without a refetch. A reload would also work and would repaint every control the operator
|
||||||
|
// is looking at, including the one they just changed.
|
||||||
|
commit(into) {
|
||||||
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
||||||
|
if (!box) return;
|
||||||
|
box.querySelectorAll('[data-ctl]').forEach(el => { el.dataset.orig = readCtl(el); });
|
||||||
|
box.querySelectorAll('.vv-cf-field.dirty').forEach(f => f.classList.remove('dirty'));
|
||||||
|
},
|
||||||
|
|
||||||
|
// One delegated listener for a whole container, however many fields it holds.
|
||||||
|
wire(into, onChange) {
|
||||||
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
||||||
|
if (!box || box.dataset.vvWired === '1') return;
|
||||||
|
box.dataset.vvWired = '1';
|
||||||
|
|
||||||
|
const refresh = el => {
|
||||||
|
const field = el.closest('.vv-cf-field');
|
||||||
|
if (field) field.classList.toggle('dirty', readCtl(el) !== el.dataset.orig);
|
||||||
|
if (onChange) onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
box.addEventListener('input', e => { if (e.target.dataset.ctl) refresh(e.target); });
|
||||||
|
box.addEventListener('change', e => {
|
||||||
|
const el = e.target;
|
||||||
|
if (!el.dataset.ctl) return;
|
||||||
|
if (el.dataset.ctl === 'bool') {
|
||||||
|
// The switch and the word beside it are both views of the checkbox, which is the only
|
||||||
|
// thing actually holding the state.
|
||||||
|
const wrap = el.closest('.vv-cf-toggle');
|
||||||
|
if (wrap) {
|
||||||
|
const track = wrap.querySelector('.vv-cf-track');
|
||||||
|
const state = wrap.querySelector('.vv-cf-state');
|
||||||
|
if (track) track.classList.toggle('on', el.checked);
|
||||||
|
if (state) state.textContent = el.checked ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.dataset.ctl === 'enum') {
|
||||||
|
const hint = el.closest('.vv-cf-body')?.querySelector('.vv-cf-opthint');
|
||||||
|
if (hint) {
|
||||||
|
let hints = {};
|
||||||
|
try { hints = JSON.parse(el.dataset.hints || '{}'); } catch (_) {}
|
||||||
|
hint.textContent = hints[el.value] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
box.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('[data-reveal]');
|
||||||
|
if (!b) return;
|
||||||
|
const inp = b.closest('.vv-cf-ctl')?.querySelector('input');
|
||||||
|
if (!inp) return;
|
||||||
|
const shown = inp.type === 'text';
|
||||||
|
inp.type = shown ? 'password' : 'text';
|
||||||
|
b.textContent = shown ? 'Show' : 'Hide';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
+114
-2
@@ -71,6 +71,7 @@
|
|||||||
// api/readscript.php source viewer contents
|
// api/readscript.php source viewer contents
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
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
|
// 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
|
// 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; }
|
border-top:1px solid #1a1a1a; }
|
||||||
.vv-ai-set-r:first-child { border-top:none; }
|
.vv-ai-set-r:first-child { border-top:none; }
|
||||||
.vv-ai-set-l { font-size:11px; color:#8a8a8a; min-width:120px; flex-shrink:0; }
|
.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; }
|
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
|
||||||
</style>
|
</style>
|
||||||
<?php vv_ai_chat_assets(); ?>
|
<?php vv_ai_chat_assets(); vv_conf_ui_assets(); ?>
|
||||||
|
|
||||||
<div id="vv-ai-wrap">
|
<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>
|
with the question — seconds for a greeting, ~18s for a substantive one.</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,6 +457,10 @@ vv_ai_chat_markup('vv-ai', [
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const API = '/plugins/varaverk/api/ai.php';
|
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
|
// 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
|
// 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').addEventListener('click', () => {
|
||||||
$('vv-ai-set-t').classList.toggle('open');
|
$('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-kind').addEventListener('change', setSummary);
|
||||||
$('vv-ai-think').addEventListener('change', setSummary);
|
$('vv-ai-think').addEventListener('change', setSummary);
|
||||||
|
|||||||
Reference in New Issue
Block a user