Give the AI subsystem one profile table and one collection, read everywhere

This commit is contained in:
Gmer4Lfe
2026-08-08 22:35:37 -04:00
parent 0f4d381ac0
commit 0f92609425
10 changed files with 355 additions and 103 deletions
+6 -1
View File
@@ -404,6 +404,11 @@ if ($can('conf_lookup')) {
// Varaverk question as chat produces a confident invention about the user's system, which is // Varaverk question as chat produces a confident invention about the user's system, which is
// precisely what retrieval exists to prevent. The user always knows which contract is in force, // precisely what retrieval exists to prevent. The user always knows which contract is in force,
// and the strict profile is the default. // and the strict profile is the default.
//
// These stay here rather than in include/ai_profiles.php with the rest of what a profile is.
// That file exists to kill duplication, and these prompts have exactly one reader — moving them
// would relocate the most delicate text in the subsystem without removing a single copy of
// anything. The registry owns the ids; the ids branch here. The trailing else is varaverk.
if ($profile === 'chat') { if ($profile === 'chat') {
// A polite instruction is not a guard. Asked "what does mover_stop.sh do in my setup", the // A polite instruction is not a guard. Asked "what does mover_stop.sh do in my setup", the
// model invented an answer and dressed it in real memory facts so it read as authoritative. // model invented an answer and dressed it in real memory facts so it read as authoritative.
@@ -652,7 +657,7 @@ $messages[] = ['role' => 'user', 'content' => $question];
// them, which is why the report prints here: it describes the request that is about to be made, // them, which is why the report prints here: it describes the request that is about to be made,
// not a reconstruction of one. // not a reconstruction of one.
if ($explain) { if ($explain) {
$caps = array_keys(array_filter(VV_AI_CAPS, fn($ps) => in_array($profile, $ps, true))); $caps = vv_ai_profile_caps($profile);
$sysChars = strlen($system); $sysChars = strlen($system);
echo "QUESTION ", $question, "\n"; echo "QUESTION ", $question, "\n";
+14 -10
View File
@@ -91,19 +91,23 @@ $t = microtime(true);
// Call vv_api_data() once — result is static-cached for the rest of this process. // Call vv_api_data() once — result is static-cached for the rest of this process.
vv_api_data(); vv_api_data();
// Must stay in step with api/monitor.php's own block. This file is what the Monitor tab // ── AI ────────────────────────────────────────────────────────────────────────
// normally reads — the endpoint only assembles a payload on a cache miss — so a key added there // One collection, two consumers. vv_ai_stats() is the expensive part of the AI subsystem —
// and not here leaves the card that consumes it loading forever on every ordinary page load, // roughly a second, most of it waiting on Ollama and nvidia-smi — and it is written to its own
// and working on the one request that happens to miss the cache. // cache here so the AI tab's banner, the Scheduler dock and the Monitor row all read the same
// numbers from the same moment instead of each paying for their own.
//
// The monitor block is derived from that same array rather than collected again. Must stay in
// step with api/monitor.php's own block: this file is what the Monitor tab normally reads, since
// the endpoint only assembles a payload on a cache miss, so a key added there and not here
// leaves the card that consumes it loading forever on every ordinary page load and working only
// on the one request that happens to miss.
$_vv_ai = null; $_vv_ai = null;
if (vv_ai_ui_on()) { if (vv_ai_ui_on()) {
require_once $_base . '/include/ai.php'; require_once $_base . '/include/ai.php';
$_vv_ai = [ $_vv_ai_stats = vv_ai_stats();
'model' => vv_ai_config()['model'], vv_cache_write('ai', $_vv_ai_stats);
'runtime' => vv_ai_runtime_stats(), $_vv_ai = vv_ai_monitor_block($_vv_ai_stats);
'index' => vv_ai_index_stats(),
'tokens' => vv_ai_token_stats()['today'] ?? null,
];
} }
$monitor = [ $monitor = [
+16 -13
View File
@@ -102,14 +102,9 @@ header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache'); header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php'; require_once dirname(__DIR__) . '/include/ai.php';
// History depth is per profile, and decided here rather than by the page. Varaverk Assistant // History depth is per profile and still decided server-side rather than by the page — the page
// spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other // simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
// two retrieve nothing and can carry a real conversation. Reasoning is not stored in history, // Reasoning is not stored in history, so it does not compound.
// so it does not compound.
// troubleshoot carries a whole log tail into context, so its history is the shallowest of the
// four — the evidence for "why did this fail" is the log in front of it, not the conversation.
const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4, 'troubleshoot' => 2];
const VV_AI_MAX_TURNS = 3; // fallback when a profile is not recognised
const VV_AI_MAX_QUESTION = 4000; // characters const VV_AI_MAX_QUESTION = 4000; // characters
const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message
const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped
@@ -153,8 +148,16 @@ if (!vv_ai_enabled()) {
} }
// ── stats ───────────────────────────────────────────────────────────────────── // ── stats ─────────────────────────────────────────────────────────────────────
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
// every open tab and used to pay a full collection each time — around a second, most of it spent
// waiting on Ollama and nvidia-smi — for numbers that only change when the writer next runs.
//
// ?live=1 bypasses it, for the case where something was just changed and the point is to see the
// result. A missing cache always falls back to collecting, so the cache can never be the reason
// the banner fails to render.
if ($action === 'stats') { if ($action === 'stats') {
echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]); echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]);
exit; exit;
} }
@@ -232,7 +235,7 @@ if ($action === 'chat_save') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$profile = trim($_POST['profile'] ?? 'chat'); $profile = trim($_POST['profile'] ?? 'chat');
if (!isset(VV_AI_PROFILES[$profile])) { if (!vv_ai_profile_ok($profile)) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit; echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
} }
@@ -250,7 +253,7 @@ if ($action === 'chat_save') {
// saved under one profile can be reopened under another, and the reopened turn is trimmed // saved under one profile can be reopened under another, and the reopened turn is trimmed
// again on the way back out by ask — so storing a little more than any single profile will // again on the way back out by ask — so storing a little more than any single profile will
// send costs nothing and keeps the transcript readable. // send costs nothing and keeps the transcript readable.
$cap = max(VV_AI_PROFILES) * 2; $cap = vv_ai_profiles_max_turns() * 2;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap); if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean); $r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean);
@@ -308,10 +311,10 @@ if ($action === 'ask') {
} }
$profile = trim($_POST['profile'] ?? 'varaverk'); $profile = trim($_POST['profile'] ?? 'varaverk');
if (!isset(VV_AI_PROFILES[$profile])) { if (!vv_ai_profile_ok($profile)) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit; echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
} }
$maxTurns = VV_AI_PROFILES[$profile] ?? VV_AI_MAX_TURNS; $maxTurns = vv_ai_profile_turns($profile);
// Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name. // Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
// The scheduler page sends it so a question can say "this setting" and mean something; the // The scheduler page sends it so a question can say "this setting" and mean something; the
+5 -6
View File
@@ -100,15 +100,14 @@ vv_api_data();
// //
// Null on any host that is not the AI host or has AI_ENABLED false, which is also what makes the // Null on any host that is not the AI host or has AI_ENABLED false, which is also what makes the
// row absent rather than empty there. Same shape as every other optional subsystem on this page. // row absent rather than empty there. Same shape as every other optional subsystem on this page.
// Reads the shared 'ai' cache the writer maintains and only collects on a miss. Landing here at
// all already means the monitor cache missed; paying a second full AI collection on top of that
// would make the slowest request on this page slower still, for figures a background writer
// refreshed under a minute ago.
$_vv_ai = null; $_vv_ai = null;
if (vv_ai_ui_on()) { if (vv_ai_ui_on()) {
require_once dirname(__DIR__) . '/include/ai.php'; require_once dirname(__DIR__) . '/include/ai.php';
$_vv_ai = [ $_vv_ai = vv_ai_monitor_block(vv_ai_stats_cached(isset($_GET['live'])));
'model' => vv_ai_config()['model'],
'runtime' => vv_ai_runtime_stats(),
'index' => vv_ai_index_stats(),
'tokens' => vv_ai_token_stats()['today'] ?? null,
];
} }
echo json_encode([ echo json_encode([
+41 -42
View File
@@ -82,48 +82,10 @@ require_once __DIR__ . '/config.php';
define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs'); define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs');
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc']; const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc'];
// ── What each profile is allowed to see and do ─────────────────────────────────────────────── // Profiles — what each one is, and what it is allowed to see and do. One table, in one file,
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be // read by everything: this endpoint, the worker, the shared chat include and the Scheduler dock.
// enforced rather than requested. This table is that half, in one place. // vv_ai_profile_can() and friends come from there.
// require_once __DIR__ . '/ai_profiles.php';
// It exists because the alternative already failed. The same permissions used to live as a dozen
// `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the worker,
// and answering "may chat ever be shown a log?" meant reading all of them. It could — a gate
// added for run-outcome questions granted it by omission, and the chat profile, whose entire
// value is that it has NOT been shown this installation, was one phrasing away from being handed
// a health sweep and 120 lines of log. Nothing about that was visible at the point of the
// mistake. Here it would have been one missing word on one line.
//
// A capability is permission, not need. varaverk holds 'health' but only attaches it when the
// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an input
// is warranted, this decides whether it is allowed, and a gate can never widen the grant.
//
// The ordering is deliberate: chat holds nothing, and that emptiness is a guarantee, not an
// oversight. Anything added to it stops being general chat and becomes an assistant that
// sometimes lies about this installation.
const VV_AI_CAPS = [
// retrieval passages from the index, and the kind filter the page exposes for them
'retrieve' => ['varaverk', 'troubleshoot'],
'kind_filter' => ['varaverk'],
// live health sweep measured at question time
'health' => ['varaverk', 'troubleshoot'],
// run record + log tail for a script named in the question
'run_evidence' => ['varaverk', 'troubleshoot'],
// log tail for whatever the operator currently has open
'scoped_log' => ['troubleshoot'],
// operator-written history of what previously went wrong with this thing
'incidents' => ['varaverk', 'troubleshoot'],
// deterministic "where does this conf key actually live" lookup
'conf_lookup' => ['varaverk', 'troubleshoot'],
// may file a bug report against Varaverk itself
'file_bugs' => ['troubleshoot'],
// destructive-operation scan of generated shell
'code_scan' => ['code'],
];
function vv_ai_profile_can(string $profile, string $cap): bool {
return in_array($profile, VV_AI_CAPS[$cap] ?? [], true);
}
// Whether a General Chat message is really about this installation. Shared by the deterministic // Whether a General Chat message is really about this installation. Shared by the deterministic
// backstop and the handoff, so both agree by construction: a question the backstop would have // backstop and the handoff, so both agree by construction: a question the backstop would have
@@ -472,6 +434,43 @@ function vv_ai_stats(): array {
]; ];
} }
// ── Shared collection ─────────────────────────────────────────────────────────
// vv_ai_stats() costs about a second on this host — vv_ai_runtime_stats() alone is 60-480ms
// depending on how quickly Ollama and nvidia-smi answer, and it was being paid by the AI tab
// every 30 seconds per open tab, plus again by anything else that wanted the same numbers.
//
// So it is collected once, by Tools/api_cache_writer.php, into the 'ai' cache; every surface
// reads that. This is the same arrangement the monitor and arrs payloads already use and for the
// same reason — polling faster cannot make the figures newer, it only decides how soon a page
// notices the writer's update.
//
// ?live=1 stays available for the one case that needs it: you changed something and want to see
// the result rather than a payload written before you changed it.
function vv_ai_stats_cached(bool $live = false): array {
if (!$live) {
$c = vv_cache_read('ai', 300);
if ($c !== null) return $c;
}
return vv_ai_stats();
}
// The Monitor tab's slice of the same collection. Derived rather than collected: taking the AI
// row's figures from a second call to vv_ai_runtime_stats() would pay the whole cost twice per
// cache write, and — worse — the dashboard and the AI tab could disagree about whether the model
// is resident, because they would have asked at different moments.
//
// Tokens are not part of vv_ai_stats(): that function's shape is the AI tab's banner contract,
// and the ledger read is 15ms, so it is fetched here rather than widening the payload everything
// else carries.
function vv_ai_monitor_block(array $stats): array {
return [
'model' => $stats['model'] ?? '',
'runtime' => $stats['runtime'] ?? [],
'index' => $stats['index'] ?? [],
'tokens' => vv_ai_token_stats()['today'] ?? null,
];
}
// Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string]. // Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string].
function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array { function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array {
$cfg = vv_ai_config(); $cfg = vv_ai_config();
+64 -24
View File
@@ -57,10 +57,16 @@
// vv_ai_chat_list_markup($prefix) the stored-conversations list container // vv_ai_chat_list_markup($prefix) the stored-conversations list container
// //
// DEPENDS ON // DEPENDS ON
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete // include/ai_profiles.php the profile registry, served to the browser rather than restated
// api/readscript.php source viewer contents // api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// Required directly, not assumed. The Monitor tab pulls this file in without include/ai.php,
// so relying on something else having loaded the registry first works on the AI tab and fatals
// on the dashboard.
require_once __DIR__ . '/ai_profiles.php';
// Emitted once even if two instances are rendered. A second copy of the script would re-register // Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of // the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters. // every keyframe, so the guard is cheaper than reasoning about whether it matters.
@@ -145,18 +151,41 @@ function vv_ai_chat_assets(): void {
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; } .vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */ /* ── Compact form, for a chat living inside a Monitor card ──────────────── */
/* Not a different component — the same markup with the chrome pulled in. The card supplies its /* Not a different component — the same markup with the chrome pulled in. The card supplies the
own heading and border, so the transcript drops its own and the hint text goes away rather heading and the border, so the transcript drops its own and the hint text goes away rather
than wrapping to three lines at this width. */ than wrapping to three lines at this width.
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0; }
Surfaces are re-based on the card, not merely un-bordered. The standalone palette paints a
#0b0b0b transcript because on the AI tab it sits on the page ground with nothing beside it
to compare against; dropped into a .vv-card (#1e1e1e) that same fill reads as a hole cut in
the card rather than as part of it. Transparent here, and the remaining controls move to the
values the plugin already uses inside a card — .vv-btn-sm is #2a2a2a on #555 — so the whole
thing reads as one object.
This is the second hardcoded dark palette in the plugin, which is exactly one too many. When
theming happens it wants tokens (--vv-surface, --vv-line) defined once in varaverk.css, and
this block becomes a token swap instead of a second set of literals. */
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0;
background:transparent; }
.vv-ai-c .vv-ai-empty { padding:26px 14px; } .vv-ai-c .vv-ai-empty { padding:26px 14px; }
.vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; } .vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; }
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; } .vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px;
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; } background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; background:#2a2a2a;
border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-prof:hover { border-color:#888; color:#fff; }
.vv-ai-c .vv-ai-prof.active { background:#152238; border-color:#4a7ab0; color:#9bd; }
.vv-ai-c .vv-ai-prof-hint { display:none; } .vv-ai-c .vv-ai-prof-hint { display:none; }
.vv-ai-c .vv-ai-hint { display:none; } .vv-ai-c .vv-ai-hint { display:none; }
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; } .vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-msg { margin-bottom:12px; } .vv-ai-c .vv-ai-msg { margin-bottom:12px; }
.vv-ai-c .vv-ai-think { background:#161616; border-left-color:#3a3a3a; }
.vv-ai-c .vv-ai-body pre { background:#161616; border-color:#3a3a3a; }
.vv-ai-c .vv-ai-body code { background:#2a2a2a; }
.vv-ai-c .vv-ai-src { border-top-color:#333; }
.vv-ai-c .vv-ai-switch { border-top-color:#333; }
/* ── Stored conversations ───────────────────────────────────────────────── */ /* ── Stored conversations ───────────────────────────────────────────────── */
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; } .vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
@@ -171,6 +200,14 @@ function vv_ai_chat_assets(): void {
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; } .vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; } .vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
.vv-ai-crow-x:hover { color:#e57; } .vv-ai-crow-x:hover { color:#e57; }
/* Hover lifts off the card rather than sinking into it. #141414 is a highlight against the AI
tab's #0e0e0e panel and a shadow against a #1e1e1e card — the same value reads as the
opposite gesture depending on what it sits on. */
.vv-ai-c .vv-ai-crow:hover { background:#2a2a2a; }
.vv-ai-c .vv-ai-crow-t { color:#ccc; }
.vv-ai-c .vv-ai-crow-m { color:#666; }
.vv-ai-c .vv-ai-crow-x { color:#666; }
.vv-ai-c .vv-ai-none { color:#666; }
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; } .vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
/* ── Source overlay ─────────────────────────────────────────────────────── */ /* ── Source overlay ─────────────────────────────────────────────────────── */
@@ -195,21 +232,21 @@ function vv_ai_chat_assets(): void {
</div> </div>
</div> </div>
<?php
// The profile registry, served rather than restated. This used to be a literal table in the
// script below that had already drifted from the PHP — it knew three profiles where the server
// knew four, which is why troubleshoot could not be offered here and the Scheduler dock had to
// hand-roll its own labels.
vv_ai_profiles_script();
?>
<script> <script>
(function () { (function () {
const API = '/plugins/varaverk/api/ai.php'; const API = '/plugins/varaverk/api/ai.php';
// Server-side is the authority on retrieval depth and history; these are for the UI only, and // The server is the authority. It re-derives depth and capability on every request regardless
// they must not drift from VV_AI_PROFILES in api/ai.php. // of what is here — these values draw buttons and decide whether to show the kind filter, they
const PROFILES = { // do not make policy.
varaverk: { label: 'Varaverk Assistant', turns: 3, kind: true, const PROFILES = window.VvAiProfiles;
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
chat: { label: 'General Chat', turns: 8, kind: false,
hint: 'Ordinary conversation. Hands anything about this install to the assistant on its own.' },
code: { label: 'Code Sketcher', turns: 4, kind: false,
hint: 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.' },
};
window.VvAiProfiles = PROFILES;
const esc = s => String(s == null ? '' : s) const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
@@ -678,9 +715,9 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;"> <div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<div class="vv-ai-profiles" id="<?= $p ?>-profiles"> <div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<?php foreach (['varaverk' => 'Varaverk Assistant', 'chat' => 'General Chat', <?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
'code' => 'Code Sketcher'] as $key => $label): ?> <button class="vv-ai-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button"
<button class="vv-ai-prof" data-prof="<?= $key ?>" type="button"><?= $label ?></button> title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button>
<?php endforeach; ?> <?php endforeach; ?>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span> <span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div> </div>
@@ -705,9 +742,12 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
// The stored-conversations container. Rendered separately from the chat because the two live in // The stored-conversations container. Rendered separately from the chat because the two live in
// different cards on Monitor and in different parts of the column on the AI tab. // different cards on Monitor and in different parts of the column on the AI tab.
function vv_ai_chat_list_markup(string $prefix): void { //
// Takes compact for the same reason the chat does: these rows are painted against whatever they
// sit on, and a card and a panel are not the same ground.
function vv_ai_chat_list_markup(string $prefix, bool $compact = false): void {
$p = htmlspecialchars($prefix, ENT_QUOTES); $p = htmlspecialchars($prefix, ENT_QUOTES);
?> ?>
<div id="<?= $p ?>-chats"><div class="vv-ai-none">loading…</div></div> <div id="<?= $p ?>-chats"<?= $compact ? ' class="vv-ai-c"' : '' ?>><div class="vv-ai-none">loading…</div></div>
<?php <?php
} }
+192
View File
@@ -0,0 +1,192 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The one definition of what an AI profile is. Every consumer — the endpoint, the worker, the
// shared chat include, the Scheduler dock — reads it from here instead of restating it.
//
// WHY THIS EXISTS
// A profile used to be defined in five places: history depth in api/ai.php, capabilities in
// include/ai.php, label/hint/depth again in the chat's JavaScript, a prompt branch in the
// worker, and a label map in pages/scheduler.php. They had already drifted — the JavaScript
// knew three profiles where PHP knew four, so the shared chat could not offer troubleshoot at
// all and the Scheduler dock hand-rolled its own labels to compensate. The include carried a
// comment telling the next person not to let the two tables diverge, which is a comment doing
// a data structure's job.
//
// WHAT LIVES HERE, AND WHAT DELIBERATELY DOES NOT
// Here: anything more than one file needs to agree on — the set of profiles, their labels and
// hints, history depth, capabilities, and whether a profile is offered as a button.
//
// Not here: the system prompts. They are long, delicate, and have exactly one consumer, so
// moving them would be churn against the most sensitive text in the subsystem for no reduction
// in duplication. Tools/ai_chat_worker.php still owns them; it just keys off ids validated
// here rather than an if-chain that invents its own vocabulary.
//
// CAPABILITIES ARE PER PROFILE, NOT PER CAPABILITY
// The old table was inverted — capability => [profiles] — which reads well when adding a
// capability and badly when answering the question actually asked at runtime, which is always
// "what can this profile do". Same content, turned the right way round.
//
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be
// enforced rather than requested. The caps list is that half.
//
// It exists because the alternative already failed. The same permissions used to live as a
// dozen `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the
// worker, and answering "may chat ever be shown a log?" meant reading all of them. It could —
// a gate added for run-outcome questions granted it by omission, and the chat profile, whose
// entire value is that it has NOT been shown this installation, was one phrasing away from
// being handed a health sweep and 120 lines of log. Nothing about that was visible at the
// point of the mistake. Here it would have been one missing word on one line.
//
// A capability is permission, not need. varaverk holds 'health' but only attaches it when the
// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an
// input is warranted, this decides whether it is allowed, and a gate can never widen the grant.
//
// chat holding an empty capability list is a guarantee, not an oversight. Anything added to it
// stops being general chat and becomes an assistant that sometimes lies about this
// installation.
//
// EXPORTS
// vv_ai_profiles() the whole table
// vv_ai_profile($id) one entry, or null
// vv_ai_profile_ok($id) is this a real profile
// vv_ai_profile_turns($id) history depth in turns
// vv_ai_profile_can($id,$cap) capability check
// vv_ai_profile_caps($id) every capability a profile holds
// vv_ai_profile_label($id) display name, falling back to the id
// vv_ai_profiles_ui() those offered as buttons, in order
// vv_ai_profiles_max_turns() deepest window of any profile
// vv_ai_profiles_client() the subset the browser needs, for json_encode
// vv_ai_profiles_script() publishes that subset as window.VvAiProfiles, once per page
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// turns — conversation depth. Varaverk Assistant spends ~2500 of its 16384 on retrieved
// passages so it cannot afford much; troubleshoot carries a whole log tail and can
// afford least of all; the two that retrieve nothing can hold a real conversation.
// caps — see VV_AI_CAP_MEANING below.
// ui — appears in the profile bar. troubleshoot is false because it is entered by opening a
// log on the Scheduler tab, not by choosing it: it is a context you are in, and a
// button offering it from the dashboard would produce a troubleshooting contract with
// nothing to troubleshoot.
const VV_AI_PROFILES_DEF = [
'varaverk' => [
'label' => 'Varaverk Assistant',
'short' => 'Assistant',
'hint' => 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.',
'turns' => 3,
'ui' => true,
'caps' => ['retrieve', 'kind_filter', 'health', 'run_evidence', 'incidents', 'conf_lookup'],
],
'chat' => [
'label' => 'General Chat',
'short' => 'Chat',
'hint' => 'Ordinary conversation. Hands anything about this install to the assistant on its own.',
'turns' => 8,
'ui' => true,
'caps' => [],
],
'code' => [
'label' => 'Code Sketcher',
'short' => 'Code',
'hint' => 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.',
'turns' => 4,
'ui' => true,
'caps' => ['code_scan'],
],
'troubleshoot' => [
'label' => 'Troubleshoot',
'short' => 'Troubleshoot',
'hint' => 'Reasons from the log you have open, then the docs. Files a bug only when the evidence shows one.',
'turns' => 2,
'ui' => false,
'caps' => ['retrieve', 'health', 'run_evidence', 'scoped_log', 'incidents', 'conf_lookup', 'file_bugs'],
],
];
// Documentation only — nothing branches on it. Kept beside the table because a capability name
// with no stated meaning is how one ends up granted to a profile that should not have it.
const VV_AI_CAP_MEANING = [
'retrieve' => 'retrieval passages from the documentation index',
'kind_filter' => 'the retrieval kind filter the page exposes',
'health' => 'live health sweep measured at question time',
'run_evidence' => 'run record and log tail for a script named in the question',
'scoped_log' => 'log tail for whatever the operator currently has open',
'incidents' => 'operator-written history of what previously went wrong with this thing',
'conf_lookup' => 'deterministic "where does this conf key actually live" lookup',
'file_bugs' => 'may file a bug report against Varaverk itself',
'code_scan' => 'destructive-operation scan of generated shell',
];
function vv_ai_profiles(): array { return VV_AI_PROFILES_DEF; }
function vv_ai_profile(string $id): ?array {
return VV_AI_PROFILES_DEF[$id] ?? null;
}
function vv_ai_profile_ok(string $id): bool {
return isset(VV_AI_PROFILES_DEF[$id]);
}
// Falls back rather than throwing. An unknown id reaching here is a caller that failed to
// validate, and the shallowest window is the safe way to be wrong: it spends the least context.
function vv_ai_profile_turns(string $id): int {
return (int)(VV_AI_PROFILES_DEF[$id]['turns'] ?? 3);
}
function vv_ai_profile_caps(string $id): array {
return VV_AI_PROFILES_DEF[$id]['caps'] ?? [];
}
function vv_ai_profile_can(string $id, string $cap): bool {
return in_array($cap, VV_AI_PROFILES_DEF[$id]['caps'] ?? [], true);
}
function vv_ai_profile_label(string $id): string {
return VV_AI_PROFILES_DEF[$id]['label'] ?? $id;
}
// Insertion order is the button order, and varaverk is first because the strict profile is the
// one to land on: a misuse there costs "the docs don't cover that" rather than an invented claim
// about the system.
function vv_ai_profiles_ui(): array {
return array_filter(VV_AI_PROFILES_DEF, fn($p) => !empty($p['ui']));
}
// The cap api/ai.php applies when storing a conversation, which has to hold the deepest window
// any profile could later ask for — a chat saved under one profile can be reopened under another.
function vv_ai_profiles_max_turns(): int {
return max(array_column(VV_AI_PROFILES_DEF, 'turns'));
}
// What the browser needs, and nothing more. The prompts are not here to be sent and the server
// re-derives depth and capability on every request regardless — this is for drawing buttons and
// deciding whether to show the kind filter, not for the client to make policy with.
function vv_ai_profiles_client(): array {
$out = [];
foreach (VV_AI_PROFILES_DEF as $id => $p) {
$out[$id] = [
'label' => $p['label'],
'short' => $p['short'],
'hint' => $p['hint'],
'turns' => $p['turns'],
'ui' => (bool)$p['ui'],
'kind' => in_array('kind_filter', $p['caps'], true),
];
}
return $out;
}
// Publishes the registry to the browser as window.VvAiProfiles, once per page however many
// surfaces ask for it. Emitted as its own tag so any script block that consumes it stays pure
// JavaScript and remains syntax-checkable outside PHP.
//
// Both the shared chat include and the Scheduler dock call this. Before it existed the dock had
// its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }` map, which is how a fourth
// copy of the profile list came to exist in the first place.
function vv_ai_profiles_script(): void {
static $done = false;
if ($done) return;
$done = true;
echo '<script>window.VvAiProfiles = '
. json_encode(vv_ai_profiles_client(), JSON_UNESCAPED_SLASHES) . ";</script>\n";
}
+7 -3
View File
@@ -425,8 +425,12 @@ vv_ai_chat_markup('vv-ai', [
}); });
box.innerHTML = html; box.innerHTML = html;
} }
function loadBanner() { // The 30-second tick reads the shared cache; a completed turn does not. Finishing a turn is
fetch(API + '?action=stats').then(r => r.json()) // precisely the event that changes what this banner reports — a cold model becomes resident,
// VRAM moves, context is now allocated — so re-rendering it from a payload written before the
// turn would show the operator the state they had just watched themselves leave.
function loadBanner(live) {
fetch(API + '?action=stats' + (live ? '&live=1' : '')).then(r => r.json())
.then(d => { if (d.ok) renderBanner(d.stats); }) .then(d => { if (d.ok) renderBanner(d.stats); })
.catch(() => {}); .catch(() => {});
} }
@@ -673,7 +677,7 @@ vv_ai_chat_markup('vv-ai', [
thinkEl: 'vv-ai-think', thinkEl: 'vv-ai-think',
empty: "Ask Varaverk about itself. Answers come only from this installation's own " empty: "Ask Varaverk about itself. Answers come only from this installation's own "
+ 'documentation, with sources.', + 'documentation, with sources.',
onTurn: () => { loadBanner(); loadTokens(); }, onTurn: () => { loadBanner(true); loadTokens(); },
onChats: id => { if (chatList) chatList.setActive(id); }, onChats: id => { if (chatList) chatList.setActive(id); },
}); });
+3 -3
View File
@@ -317,7 +317,7 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
Conversations Conversations
</span> </span>
</h3> </h3>
<?php vv_ai_chat_list_markup('vv-mon-ai'); ?> <?php vv_ai_chat_list_markup('vv-mon-ai', true); ?>
</div> </div>
<!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different <!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different
@@ -1909,12 +1909,12 @@ function vvRenderAi(ai) {
h += line('Ollama', 'unreachable', 'color:#e57;'); h += line('Ollama', 'unreachable', 'color:#e57;');
} else if (!rt.loaded) { } else if (!rt.loaded) {
h += line('Model', 'not loaded', 'color:#ffb74d;'); h += line('Model', 'not loaded', 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#444;margin:-2px 0 7px;">loads on first question</div>`; h += `<div style="font-size:10px;color:#666;margin:-2px 0 7px;">loads on first question</div>`;
} else { } else {
const p = rt.offload_pct; const p = rt.offload_pct;
const full = p === 100; const full = p === 100;
h += line('GPU offload', p === null ? '—' : p + '%', full ? 'color:#6fcf97;' : 'color:#ffb74d;'); h += line('GPU offload', p === null ? '—' : p + '%', full ? 'color:#6fcf97;' : 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#444;margin:-2px 0 7px;">` h += `<div style="font-size:10px;color:#666;margin:-2px 0 7px;">`
+ (full ? 'all on GPU' : 'layers on CPU — slow') + `</div>`; + (full ? 'all on GPU' : 'layers on CPU — slow') + `</div>`;
if (rt.context) h += line('Context', rt.context.toLocaleString()); if (rt.context) h += line('Context', rt.context.toLocaleString());
} }
+7 -1
View File
@@ -64,6 +64,7 @@
// api/rsync_standalone.php // api/rsync_standalone.php
require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/docs.php'; require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/ai_profiles.php';
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived // Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
// path constants — those are not conf keys, but they are exactly what a reader needs resolved // path constants — those are not conf keys, but they are exactly what a reader needs resolved
@@ -1059,6 +1060,7 @@ Still the same two servers, two households, the same media stack running itself.
At rest it is one input row. Answers expand it upward and the views above shrink to At rest it is one input row. Answers expand it upward and the views above shrink to
suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays
so the thing you are asking about stays on screen. Collapsing keeps the conversation. --> so the thing you are asking about stays on screen. Collapsing keeps the conversation. -->
<?php vv_ai_profiles_script(); ?>
<div id="vv-ai-dock"> <div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div> <div id="vv-ai-dock-body" style="display:none"></div>
<div id="vv-ai-dock-bar"> <div id="vv-ai-dock-bar">
@@ -2367,8 +2369,12 @@ function vvAiDockScope(profile, label, target) {
const had = vvAiHist.length > 0; const had = vvAiHist.length > 0;
vvAiProfile = profile; vvAiProfile = profile;
vvAiScope = target; vvAiScope = target;
// Short label from the registry, not a literal map. The map that used to be here was the
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it
// had not been told about — so a new profile would have shown up in the chip as the strict one.
const _p = (window.VvAiProfiles || {})[profile];
document.getElementById('vv-ai-dock-chip').textContent = document.getElementById('vv-ai-dock-chip').textContent =
({ code: 'Code', troubleshoot: 'Troubleshoot' }[profile] || 'Assistant') + ' · ' + label; (_p ? _p.short : profile) + ' · ' + label;
// Transcript stays, history sent to the model resets — the same rule the AI tab's profile // Transcript stays, history sent to the model resets — the same rule the AI tab's profile
// buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a // buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a