diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index cf2ec3f..f5a0dcb 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -404,6 +404,11 @@ if ($can('conf_lookup')) { // 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, // 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') { // 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. @@ -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, // not a reconstruction of one. 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); echo "QUESTION ", $question, "\n"; diff --git a/Plugin/unraid/Tools/api_cache_writer.php b/Plugin/unraid/Tools/api_cache_writer.php index 123cbb7..10339d3 100644 --- a/Plugin/unraid/Tools/api_cache_writer.php +++ b/Plugin/unraid/Tools/api_cache_writer.php @@ -91,19 +91,23 @@ $t = microtime(true); // Call vv_api_data() once — result is static-cached for the rest of this process. vv_api_data(); -// Must stay in step with api/monitor.php's own block. This file is what the Monitor tab -// normally reads — 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 on the one request that happens to miss the cache. +// ── AI ──────────────────────────────────────────────────────────────────────── +// One collection, two consumers. vv_ai_stats() is the expensive part of the AI subsystem — +// roughly a second, most of it waiting on Ollama and nvidia-smi — and it is written to its own +// 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; if (vv_ai_ui_on()) { require_once $_base . '/include/ai.php'; - $_vv_ai = [ - 'model' => vv_ai_config()['model'], - 'runtime' => vv_ai_runtime_stats(), - 'index' => vv_ai_index_stats(), - 'tokens' => vv_ai_token_stats()['today'] ?? null, - ]; + $_vv_ai_stats = vv_ai_stats(); + vv_cache_write('ai', $_vv_ai_stats); + $_vv_ai = vv_ai_monitor_block($_vv_ai_stats); } $monitor = [ diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index edeb7ca..a42d44e 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -102,14 +102,9 @@ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/ai.php'; -// History depth is per profile, and decided here rather than by the page. Varaverk Assistant -// spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other -// two retrieve nothing and can carry a real conversation. Reasoning is not stored in history, -// 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 +// History depth is per profile and still decided server-side rather than by the page — the page +// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them. +// Reasoning is not stored in history, so it does not compound. const VV_AI_MAX_QUESTION = 4000; // characters const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped @@ -153,8 +148,16 @@ if (!vv_ai_enabled()) { } // ── 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') { - echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]); + echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]); exit; } @@ -232,7 +235,7 @@ if ($action === 'chat_save') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $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; } @@ -250,7 +253,7 @@ if ($action === 'chat_save') { // 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 // 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); $r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean); @@ -308,10 +311,10 @@ if ($action === 'ask') { } $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; } - $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. // The scheduler page sends it so a question can say "this setting" and mean something; the diff --git a/Plugin/unraid/api/monitor.php b/Plugin/unraid/api/monitor.php index bb852d4..09b9460 100644 --- a/Plugin/unraid/api/monitor.php +++ b/Plugin/unraid/api/monitor.php @@ -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 // 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; if (vv_ai_ui_on()) { require_once dirname(__DIR__) . '/include/ai.php'; - $_vv_ai = [ - 'model' => vv_ai_config()['model'], - 'runtime' => vv_ai_runtime_stats(), - 'index' => vv_ai_index_stats(), - 'tokens' => vv_ai_token_stats()['today'] ?? null, - ]; + $_vv_ai = vv_ai_monitor_block(vv_ai_stats_cached(isset($_GET['live']))); } echo json_encode([ diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index f75de51..ee1952a 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -82,48 +82,10 @@ require_once __DIR__ . '/config.php'; define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs'); const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc']; -// ── What each profile is allowed to see and do ─────────────────────────────────────────────── -// A profile is a contract plus a set of inputs, and the inputs are the half that has to be -// enforced rather than requested. This table is that half, in one place. -// -// 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); -} +// Profiles — what each one is, and what it is allowed to see and do. One table, in one file, +// read by everything: this endpoint, the worker, the shared chat include and the Scheduler dock. +// vv_ai_profile_can() and friends come from there. +require_once __DIR__ . '/ai_profiles.php'; // 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 @@ -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]. function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array { $cfg = vv_ai_config(); diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index ab83570..788893d 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -57,10 +57,16 @@ // vv_ai_chat_list_markup($prefix) the stored-conversations list container // // DEPENDS ON -// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete -// api/readscript.php source viewer contents +// include/ai_profiles.php the profile registry, served to the browser rather than restated +// 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 // 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. @@ -145,18 +151,41 @@ function vv_ai_chat_assets(): void { .vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; } /* ── 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 - own heading and border, so the transcript drops its own and the hint text goes away rather - 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; } +/* Not a different component — the same markup with the chrome pulled in. The card supplies the + 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. + + 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-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-prof { padding:3px 9px; font-size:10px; } +.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; + 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-hint { display:none; } .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-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 ───────────────────────────────────────────────── */ .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:hover .vv-ai-crow-x { visibility:visible; } .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; } /* ── Source overlay ─────────────────────────────────────────────────────── */ @@ -195,21 +232,21 @@ function vv_ai_chat_assets(): void { + \n"; +} diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index e6f415b..e5d7f00 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -425,8 +425,12 @@ vv_ai_chat_markup('vv-ai', [ }); box.innerHTML = html; } - function loadBanner() { - fetch(API + '?action=stats').then(r => r.json()) + // The 30-second tick reads the shared cache; a completed turn does not. Finishing a turn is + // 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); }) .catch(() => {}); } @@ -673,7 +677,7 @@ vv_ai_chat_markup('vv-ai', [ thinkEl: 'vv-ai-think', empty: "Ask Varaverk about itself. Answers come only from this installation's own " + 'documentation, with sources.', - onTurn: () => { loadBanner(); loadTokens(); }, + onTurn: () => { loadBanner(true); loadTokens(); }, onChats: id => { if (chatList) chatList.setActive(id); }, }); diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 6e1aa27..5b9a99c 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -317,7 +317,7 @@ if (vv_ai_ui_on()) vv_ai_chat_assets(); Conversations - + +
@@ -2367,8 +2369,12 @@ function vvAiDockScope(profile, label, target) { const had = vvAiHist.length > 0; vvAiProfile = profile; 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 = - ({ 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 // buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a