Compare commits

...
3 Commits
8 changed files with 1152 additions and 397 deletions
+14
View File
@@ -1645,6 +1645,20 @@
AI_MEMORY_FILE="$DATA_DIR/ai_memory.md"
AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded
# ━━━ AI Stored Conversations ━━━
# How many past conversations the AI tab and the Monitor tab's AI row keep. One JSON file per
# conversation under DATA_DIR/ai_chats, saved automatically when a turn completes; the oldest
# drop off once the count is exceeded.
#
# A cap rather than a retention age. These are read by picking one out of a short list, and a
# list you have to scroll is a list you stop using — the useful window is the handful of things
# you were recently working on, which is a count, not a date.
#
# Not indexed and never retrieved into a prompt on their own. A stored chat only re-enters the
# model's context when the operator explicitly reopens it, and it is re-validated per message on
# the way in, exactly as live history is.
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
# ━━━ AI Token Accounting ━━━
# One row per completed turn, appended by whichever path ran it — the WebGUI worker and the
# ai_query.sh CLI both write here, so the totals are not silently the tab's alone. Format is
+16
View File
@@ -91,6 +91,21 @@ $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.
$_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,
];
}
$monitor = [
'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
@@ -118,6 +133,7 @@ $monitor = [
'vms' => vv_get_vms(),
'docker_folders' => vv_get_docker_folders(),
'remote_hosts' => vv_remote_hosts_stats(),
'ai' => $_vv_ai,
'_api_status' => vv_api_get_status(),
'ts' => time(),
];
+67 -1
View File
@@ -42,7 +42,9 @@
// History is validated per message, not trusted as a blob.
// Role must be user or assistant, content must be a non-empty string, and each is
// truncated. A crafted history could otherwise inject a system role or push the context
// past the offload ceiling.
// past the offload ceiling. chat_save applies the same validation, because a stored
// conversation is replayed into a later prompt when it is reopened — an unchecked role
// written there is an injection that survives a reload rather than one turn.
//
// The question is length-capped before it reaches a command line.
// It is passed to the worker through escapeshellarg, but an unbounded string would still
@@ -61,15 +63,21 @@
// GET ?action=stats banner payload
// GET ?action=poll&token=<hex32> job state
// GET ?action=memory_get the operator memory file and its budget
// GET ?action=chats stored conversations, newest first, metadata only
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
// POST action=memory_set memory=… replace the memory file
// POST action=clear token=<hex32> discard a finished job
// POST action=chat_save [id=<hex32>] profile=… messages=<JSON>
// POST action=chat_delete id=<hex32>
//
// RESPONSE
// stats {"ok":true,"stats":{…}}
// ask {"ok":true,"token":"<hex32>"}
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
// clear {"ok":true}
// chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
// chat_save {"ok":true,"id":"<hex32>","title":…}
// {"ok":false,"error":…}
//
// DEPENDS ON
@@ -201,6 +209,64 @@ if ($action === 'clear') {
exit;
}
// ── chats ─────────────────────────────────────────────────────────────────────
// Stored conversations. Listing and reading are GET because they change nothing; saving and
// deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here.
//
// Messages are validated per message on the way in, exactly as ask validates history and for
// the same reason: a stored chat is replayed into a later prompt when the operator reopens it,
// so a crafted role in the store would be an injection that survives a reload.
if ($action === 'chats') {
echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]);
exit;
}
if ($action === 'chat_get') {
$chat = vv_ai_chat_read(trim($_GET['id'] ?? ''));
if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; }
echo json_encode(['ok' => true, 'chat' => $chat]);
exit;
}
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])) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
}
$clean = [];
$msgs = json_decode($_POST['messages'] ?? '[]', true);
if (is_array($msgs)) {
foreach ($msgs as $m) {
$role = $m['role'] ?? '';
$text = trim((string)($m['content'] ?? ''));
if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
}
}
// Capped at the deepest profile's window rather than that of the profile in hand. A chat
// 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;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean);
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
: 'FAILED: ' . $r['error']));
echo json_encode($r);
exit;
}
if ($action === 'chat_delete') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$ok = vv_ai_chat_delete(trim($_POST['id'] ?? ''));
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']);
exit;
}
// ── bugs / bug_close ──────────────────────────────────────────────────────────
// Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is
// a POST, like every other mutation in this plugin.
+20
View File
@@ -64,6 +64,7 @@
// A flat object of the keys listed in the assembly below, plus _api_status and ts.
//
// DEPENDS ON
// include/ai.php AI residency and index figures, only when vv_ai_ui_on()
// include/config.php vv_cache_read()
// include/common.php raw hardware metrics — system, cpu, mem, net, gpu, disks,
// ups, parity, containers, transcodes, remote roll-ups
@@ -92,6 +93,24 @@ require_once dirname(__DIR__) . '/include/docker_folders.php';
// Pre-warm the API cache with one request (shared by all API-first functions below).
vv_api_data();
// AI residency, for the Monitor tab's AI row. Collected here rather than by that card polling
// api/ai.php on its own cycle: vv_ai_runtime_stats() calls out to Ollama and shells nvidia-smi,
// and this payload is written once a minute by the background writer — a card polling it
// directly would pay both costs every five seconds on every open tab.
//
// 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.
$_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,
];
}
echo json_encode([
'system' => vv_system_info(),
'fallback' => vv_fallback_state(),
@@ -119,6 +138,7 @@ echo json_encode([
'vms' => vv_get_vms(),
'docker_folders' => vv_get_docker_folders(),
'remote_hosts' => vv_remote_hosts_stats(),
'ai' => $_vv_ai,
'_api_status' => vv_api_get_status(),
'ts' => time(),
]);
+124
View File
@@ -1150,6 +1150,130 @@ function vv_ai_incidents_for(string $scope, int $max = 4): array {
return array_slice(array_reverse($hits), 0, max(1, $max));
}
// ── Stored conversations ──────────────────────────────────────────────────────
// Chats live in DATA_DIR, not /tmp, because the point of storing them is that they outlive a
// reboot — the job files above are deliberately the opposite. One file per conversation, same
// shape as ai_bugs: a directory of small JSON records is trivially prunable and a corrupt one
// costs a single chat rather than the whole history.
//
// The store is capped, and pruning happens on write rather than on a schedule. Nothing else
// runs often enough to be trusted with it, and an unbounded directory here would quietly grow
// for as long as the operator keeps talking to the assistant.
function vv_ai_chats_dir(): string {
$d = DATA_DIR . '/ai_chats';
if (!is_dir($d)) @mkdir($d, 0755, true);
return $d;
}
function vv_ai_chats_max(): int {
$n = (int)(vv_conf_vars()['AI_CHAT_HISTORY_MAX'] ?? 10);
return max(1, min(50, $n));
}
// Hex-only, for exactly the reason vv_ai_job_path() is: the id composes a path. Ids are minted
// server-side and the client only ever echoes one back, so anything else is a caller that has
// no business naming a file here.
function vv_ai_chat_path(string $id): ?string {
if (!preg_match('/^[0-9a-f]{32}$/', $id)) return null;
return vv_ai_chats_dir() . '/' . $id . '.json';
}
// The first thing asked, which is what the operator will recognise the conversation by. Falls
// back rather than returning empty: a blank row in the list is indistinguishable from a broken
// one.
function vv_ai_chat_title(array $messages): string {
foreach ($messages as $m) {
if (($m['role'] ?? '') !== 'user') continue;
$t = trim(preg_replace('/\s+/', ' ', (string)($m['content'] ?? '')));
if ($t !== '') return mb_substr($t, 0, 80);
}
return 'Untitled conversation';
}
// Newest first, metadata only. The list card renders ten rows and none of them need the
// transcript — sending every message of every stored chat to draw a sidebar would be the most
// expensive read on the monitor page.
function vv_ai_chats_list(): array {
$out = [];
foreach (glob(vv_ai_chats_dir() . '/*.json') ?: [] as $f) {
$d = json_decode(@file_get_contents($f) ?: '', true);
if (!is_array($d) || empty($d['id'])) continue;
$out[] = [
'id' => $d['id'],
'ts' => (int)($d['ts'] ?? 0),
'profile' => (string)($d['profile'] ?? 'chat'),
'title' => (string)($d['title'] ?? 'Untitled conversation'),
'turns' => (int)($d['turns'] ?? count($d['messages'] ?? [])),
];
}
usort($out, fn($a, $b) => $b['ts'] <=> $a['ts']);
return $out;
}
function vv_ai_chat_read(string $id): ?array {
$p = vv_ai_chat_path($id);
if ($p === null || !file_exists($p)) return null;
$d = json_decode(@file_get_contents($p) ?: '', true);
return is_array($d) ? $d : null;
}
function vv_ai_chat_delete(string $id): bool {
$p = vv_ai_chat_path($id);
if ($p === null || !file_exists($p)) return false;
return @unlink($p);
}
// Oldest go first, by stored timestamp rather than mtime — a chat that is reopened and continued
// is rewritten, and ordering on mtime would make "the one I have been using all week" look like
// the newest thing in the store while a genuinely older thread got dropped in its place.
function vv_ai_chats_prune(?int $max = null): int {
$max = $max ?? vv_ai_chats_max();
$list = vv_ai_chats_list();
$n = 0;
foreach (array_slice($list, $max) as $old) {
if (vv_ai_chat_delete($old['id'])) $n++;
}
return $n;
}
// Writes a whole conversation. An empty id mints one; a known id overwrites in place, which is
// what makes a continued conversation stay one row in the list instead of breeding a new one per
// turn. Written to a temp file and renamed, so a reader never sees half a transcript.
function vv_ai_chat_save(string $id, string $profile, array $messages): array {
if (!$messages) return ['ok' => false, 'error' => 'Nothing to save'];
if ($id === '') $id = bin2hex(random_bytes(16));
$p = vv_ai_chat_path($id);
if ($p === null) return ['ok' => false, 'error' => 'Invalid chat id'];
// Preserve the original creation time across rewrites. Ordering the list by last activity
// would be defensible, but it would also mean an old thread jumps the queue the moment it is
// reopened, and the prune above is written against creation order.
$prev = vv_ai_chat_read($id);
$created = (int)($prev['created'] ?? time());
$rec = [
'id' => $id,
'created' => $created,
'ts' => $created,
'updated' => time(),
'profile' => $profile,
'title' => vv_ai_chat_title($messages),
'turns' => (int)floor(count($messages) / 2),
'messages' => $messages,
];
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, json_encode($rec, JSON_PRETTY_PRINT)) === false
|| !@rename($tmp, $p)) {
@unlink($tmp);
return ['ok' => false, 'error' => 'Could not write ' . $p];
}
vv_ai_chats_prune();
return ['ok' => true, 'id' => $id, 'title' => $rec['title']];
}
function vv_ai_job_dir(): string {
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
return VV_AI_JOB_DIR;
+713
View File
@@ -0,0 +1,713 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The conversation surface itself — profile bar, transcript, composer, source viewer and the
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the
// Monitor tab's AI row.
//
// WHY THIS IS AN INCLUDE
// There were two places that needed a real transcript and one implementation, which meant the
// second one was going to be a copy. Everything subtle here — the busy flag that must never
// stick, URLSearchParams instead of FormData, the poll ceiling, escaping before markdown, the
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
//
// The Scheduler tab's dock is deliberately NOT built on this. It is a one-line bar that follows
// the view you have open, with its own scope chip, fix flow and incident capture; it is a
// different component that happens to talk to the same endpoint. Folding it in here would mean
// one widget with two personalities and a mode flag deciding which.
//
// INSTANCES
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance
// registers under window.__vvAiChat[prefix] and tears down the previous holder of that prefix
// on construction — Unraid swaps tab content by AJAX without unloading the old page's
// JavaScript, so the previous copy's timers are still running when the new one arrives.
//
// DESIGN PRINCIPLES
// The transcript and what the model is sent are not the same list.
// messages[] is everything on screen and everything stored. sendFrom is the index the
// model is allowed to see from. Switching profile moves sendFrom to the end rather than
// emptying the transcript: carrying cited, retrieval-grounded turns into a mode with no
// retrieval makes the model refer to sources it can no longer see, but erasing what the
// operator just read to achieve that is worse.
//
// Citations and sources are wired by delegation, not by onclick.
// Both carry a file path that came from the index. Interpolating one into an onclick
// attribute means a path containing a quote executes; data attributes plus one listener on
// the transcript removes the whole class rather than escaping around it.
//
// Storage is automatic and capped.
// A conversation saves itself when a turn completes and the oldest drops off past
// AI_CHAT_HISTORY_MAX. There is no Save button, because a chat worth keeping is not
// reliably one you knew was worth keeping while you were having it.
//
// OPERATIONAL SAFEGUARDS
// Every rendered string is escaped, and the minimal markdown pass runs strictly afterwards, so
// no model or file derived input can introduce markup.
//
// Every failure path ends in a visible message in the transcript. A hang is the one failure
// that reports nothing, and this endpoint has produced one: a multipart POST that left the
// browser and never reached PHP, with no status, no fatal and no entry in any log.
//
// Read-only with respect to the system. Nothing here runs a script or edits conf.
//
// EXPORTS
// vv_ai_chat_assets() styles + the VvAiChat / VvAiChatList factories, once per page
// vv_ai_chat_markup($prefix,$o) profile bar, transcript, composer for one instance
// 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
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// 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.
function vv_ai_chat_assets(): void {
static $done = false;
if ($done) return;
$done = true;
?>
<style>
/* ── Shared tone ─────────────────────────────────────────────────────────── */
.vv-ai-ok { color:#6fcf97 !important; }
.vv-ai-warn { color:#ffb74d !important; }
.vv-ai-bad { color:#e57 !important; }
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
border-radius:3px; cursor:pointer; font-family:inherit; }
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* ── Profiles ───────────────────────────────────────────────────────────── */
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; }
/* ── Transcript ─────────────────────────────────────────────────────────── */
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
.vv-ai-msg { margin-bottom:16px; }
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
overflow-x:auto; margin:8px 0; }
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
.vv-ai-cite:hover { text-decoration:underline; }
.vv-ai-danger { background:#1f0d0d; border:1px solid #4a1f1f; border-left:3px solid #e57;
border-radius:4px; padding:8px 10px; margin-bottom:9px; font-size:11px;
line-height:1.55; color:#d99; }
.vv-ai-danger strong { color:#f88; }
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
.vv-ai-think-t:hover { color:#777; border-color:#333; }
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; }
.vv-ai-think.open { display:block; }
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; margin-bottom:4px; }
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
.vv-ai-src-i:hover { color:#8a8a8a; }
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
/* ── Composer ───────────────────────────────────────────────────────────── */
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
border-radius:6px; padding:10px; background:#0e0e0e; }
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
.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; }
.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-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-msg { margin-bottom:12px; }
/* ── Stored conversations ───────────────────────────────────────────────── */
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
.vv-ai-crow { display:flex; align-items:baseline; gap:8px; padding:5px 7px; border-radius:4px;
cursor:pointer; border:1px solid transparent; }
.vv-ai-crow:hover { background:#141414; }
.vv-ai-crow.active { background:#14202c; border-color:#2d4a6a; }
.vv-ai-crow-t { font-size:11px; color:#8a8a8a; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; flex:1; min-width:0; }
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
.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; }
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
/* ── Source overlay ─────────────────────────────────────────────────────── */
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
padding:36px; }
#vv-ai-view.open { display:block; }
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
display:flex; flex-direction:column; }
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
color:#9a9a9a; white-space:pre; }
</style>
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
<div class="vv-ai-view-box">
<div class="vv-ai-view-h">
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
</div>
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
</div>
</div>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// Server-side is the authority on retrieval depth and history; these are for the UI only, and
// they must not drift from VV_AI_PROFILES in api/ai.php.
const PROFILES = {
varaverk: { label: 'Varaverk Assistant', turns: 3, kind: true,
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)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const POST_HEAD = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
function ago(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60)+'m';
if (d < 86400) return Math.floor(d/3600)+'h';
return Math.floor(d/86400)+'d';
}
// ── Source viewer, one per page ─────────────────────────────────────────
const $g = id => document.getElementById(id);
window.vvAiOpen = function (path) {
$g('vv-ai-view-t').textContent = path;
$g('vv-ai-view-b').textContent = 'Loading…';
$g('vv-ai-view').classList.add('open');
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
.then(r => r.json())
.then(d => { $g('vv-ai-view-b').textContent = d.ok ? d.content
: (d.error || 'Could not read this file.'); })
.catch(e => { $g('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
};
window.vvAiCloseView = function () { $g('vv-ai-view').classList.remove('open'); };
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
// ── Chat instance ───────────────────────────────────────────────────────
window.__vvAiChat = window.__vvAiChat || {};
window.VvAiChat = function (o) {
const P = o.prefix;
const $ = sfx => document.getElementById(P + '-' + sfx);
const onTurn = o.onTurn || function () {};
const onChats = o.onChats || function () {};
const store = o.chats !== false;
const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
// An instance already holding this prefix is a leftover from a tab swap, still holding
// timers and a busy flag over DOM nodes that no longer exist.
if (window.__vvAiChat[P]) { try { window.__vvAiChat[P].teardown(); } catch (e) {} }
let profile = PROFILES[o.profile] ? o.profile : 'varaverk';
let messages = []; // whole transcript — displayed and stored
let sendFrom = 0; // index the model is allowed to see from
let chatId = ''; // '' until the store mints one
let busy = false;
let lastSources = [];
let pendingTimer = null;
const chatEl = () => $('chat');
const scroll = () => { const c = chatEl(); c.scrollTop = c.scrollHeight; };
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
// ── Minimal markdown, applied strictly after escaping ────────────────
function fmt(text) {
let h = esc(text);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
return h;
}
// ── Transcript ───────────────────────────────────────────────────────
function addUser(text) {
clearEmpty();
chatEl().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
scroll();
}
function addPending() {
const n = el(`<div class="vv-ai-msg bot" id="${P}-pending"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
+ `<span id="${P}-phase">starting…</span>`
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
chatEl().appendChild(n); scroll();
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a
// stalled turn and a slow one look identical, and the slow case here is legitimately ~40s.
const t0 = Date.now();
clearInterval(pendingTimer);
pendingTimer = setInterval(() => {
const e = $('elapsed');
if (!e) { clearInterval(pendingTimer); return; }
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
}, 1000);
}
function phase(t) { const p = $('phase'); if (p) p.textContent = t; }
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
function sourcesHtml(sources) {
if (!sources || !sources.length) return '';
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
sources.forEach((s, i) => {
const label = [s.path, s.section, s.heading].filter(Boolean).join(' ');
h += `<div class="vv-ai-src-i" data-src="${esc(s.path)}">`
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
});
return h + '</div>';
}
function addAnswer(job) {
const p = $('pending'); if (p) p.remove();
lastSources = job.sources || [];
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
if (job.thinking) {
h += `<div class="vv-ai-think-t" data-think="1">`
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
}
// Detected from the generated code, not from the model saying so. These scripts run as
// root on a schedule, so the banner is louder than whatever prose warning it may have added.
if (job.warnings && job.warnings.length) {
h += `<div class="vv-ai-danger"><strong>Destructive — read before running.</strong> `
+ `This script ${job.warnings.map(esc).join('; ')}. `
+ `Run any dry-run form first and check the paths are what you expect.</div>`;
}
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
h += sourcesHtml(job.sources);
const t = job.timing || {};
if (t.tok_s) {
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
}
chatEl().appendChild(el(h + '</div>')); scroll();
}
function addError(msg) {
const p = $('pending'); if (p) p.remove();
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
scroll();
}
// One listener for the whole transcript, covering nodes that do not exist yet.
chatEl().addEventListener('click', e => {
const think = e.target.closest('.vv-ai-think-t');
if (think) { think.nextElementSibling.classList.toggle('open'); return; }
const src = e.target.closest('.vv-ai-src-i');
if (src && src.dataset.src) { vvAiOpen(src.dataset.src); return; }
const cite = e.target.closest('.vv-ai-cite');
if (cite) {
const s = lastSources[Number(cite.dataset.cite) - 1];
if (s && s.path) vvAiOpen(s.path);
}
});
// ── Ask / poll ───────────────────────────────────────────────────────
function send() {
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
// loop that stopped, a tab left open across a deploy — would otherwise make every later
// click a no-op with the previous "starting…" still on screen, which reads as a hang that
// produces no request and therefore no server-side trace at all.
if (busy) {
addError('A previous question is still marked in-flight, so this one was not sent. '
+ 'Reload the tab to reset it.');
return;
}
const q = $('input').value.trim();
if (!q) return;
busy = true;
$('send').disabled = true;
addUser(q);
$('input').value = '';
addPending();
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
// installed elsewhere on the page — would escape the promise chain entirely and leave the
// pending indicator up forever with nothing logged anywhere.
// URLSearchParams, not FormData. A multipart POST to this endpoint hangs with no status
// ever returned: the request leaves the browser with a valid token and correct body and
// never reaches PHP — no CSRF termination, no fatal, no entry log.
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
const thinkEl = o.thinkEl ? document.getElementById(o.thinkEl) : null;
let res;
try {
const body = new URLSearchParams({
action: 'ask',
profile: profile,
question: q,
history: JSON.stringify(sendable()),
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
think: (thinkEl ? thinkEl.checked : true) ? '1' : '0',
});
res = fetch(API, { method: 'POST', headers: POST_HEAD, body });
} catch (e) {
addError('Could not send the request: ' + (e && e.message ? e.message : e)
+ ' — this failed in the browser before reaching the server.');
finish();
return;
}
res.then(r => r.text().then(t => ({ status: r.status, text: t })))
.then(({ status, text }) => {
if (!text.trim()) {
// The CSRF prepend terminates with an empty body, so this is the shape that failure
// takes. Naming it beats a bare JSON parse error.
addError('Empty response (HTTP ' + status + '). This usually means the request was '
+ 'rejected before the endpoint ran — check the CSRF token shim.');
finish(); return;
}
let d;
try { d = JSON.parse(text); }
catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160));
finish(); return; }
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
messages.push({ role: 'user', content: q });
poll(d.token, Date.now());
})
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
}
// What the model may see: never across a profile switch, and never deeper than this
// profile's window. api/ai.php trims again regardless — this is the client half of a cap
// that exists in both places on purpose.
function sendable() {
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
return messages.slice(floor);
}
function finish() {
busy = false;
const b = $('send'); if (b) b.disabled = false;
clearInterval(pendingTimer);
}
function poll(token, started) {
if (Date.now() - started > POLL_CEIL) {
addError('Timed out waiting for a response.'); finish(); return;
}
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
const j = d.job || {};
if (j.status === 'done') {
addAnswer(j);
messages.push({ role: 'assistant', content: j.answer });
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
finish();
save();
onTurn();
return;
}
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
phase(j.status === 'generating'
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
setTimeout(() => poll(token, started), POLL_MS);
}).catch(e => { addError('Poll failed: ' + e); finish(); });
}
// ── Storage ──────────────────────────────────────────────────────────
// Fire and forget, deliberately. A store that cannot be written is not a reason to lose the
// answer that is already on screen, and the list refresh below reveals the failure anyway.
function save() {
if (!store || !messages.length) return;
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({
action: 'chat_save', id: chatId, profile,
messages: JSON.stringify(messages),
}) })
.then(r => r.json())
.then(d => { if (d.ok) { chatId = d.id; onChats(chatId); } })
.catch(() => {});
}
// A reopened conversation renders as plain turns. Sources, reasoning and timings are not
// stored: they describe one generation, and redrawing them beside a transcript that may be
// continued under a different profile would be citing evidence for an answer that is no
// longer being made.
function render() {
const c = chatEl();
if (!messages.length) { reset(); return; }
c.innerHTML = '';
messages.forEach(m => {
if (m.role === 'user') { addUser(m.content); return; }
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(m.content)}</div></div>`));
});
scroll();
}
function reset() {
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
}
function loadChat(id) {
fetch(API + '?action=chat_get&id=' + encodeURIComponent(id)).then(r => r.json()).then(d => {
if (!d.ok) { addError(d.error || 'Could not open that conversation'); return; }
const c = d.chat || {};
messages = Array.isArray(c.messages) ? c.messages : [];
// Zero, not the length: reopening is meant to continue the thread, and a floor at the
// end would send the model an empty history for a transcript full of context.
sendFrom = 0;
chatId = c.id || '';
lastSources = [];
if (PROFILES[c.profile]) applyProfile(c.profile);
render();
onChats(chatId);
}).catch(e => addError('Could not open that conversation: ' + e));
}
function newChat() {
messages = []; sendFrom = 0; chatId = ''; lastSources = [];
reset();
onChats('');
}
// ── Profiles ─────────────────────────────────────────────────────────
function applyProfile(p) {
profile = p;
const bar = $('profiles');
if (bar) bar.querySelectorAll('.vv-ai-prof').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
const hint = $('prof-hint');
if (hint) hint.textContent = PROFILES[p].hint;
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = PROFILES[p].kind ? '' : 'none';
}
// Switching moves the floor rather than clearing the transcript. Carrying cited,
// retrieval-grounded turns into a mode with no retrieval makes the model keep referring to
// sources it can no longer see; erasing what the operator just read to avoid that is worse.
function setProfile(p) {
if (!PROFILES[p] || p === profile) return;
applyProfile(p);
if (messages.length > sendFrom) {
chatEl().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(PROFILES[p].label)
+ ' — earlier turns are no longer carried</div>'));
scroll();
}
sendFrom = messages.length;
$('input').focus();
}
const bar = $('profiles');
if (bar) bar.addEventListener('click', e => {
const b = e.target.closest('.vv-ai-prof');
if (b) setProfile(b.dataset.prof);
});
// ── Wiring ───────────────────────────────────────────────────────────
$('send').addEventListener('click', send);
$('input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
});
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
// Surface any script error into the transcript. Without it a throw anywhere on the page is
// invisible unless the console happens to be open, which is how a silent hang survives a
// diagnosis session.
const onErr = e => {
if (!busy) return;
addError('Script error: ' + (e.message || 'unknown')
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
finish();
};
const onRej = e => {
if (!busy) return;
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
finish();
};
window.addEventListener('error', onErr);
window.addEventListener('unhandledrejection', onRej);
applyProfile(profile);
const inst = {
prefix: P,
setProfile, newChat, loadChat, send,
currentId: () => chatId,
teardown() {
clearInterval(pendingTimer);
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
},
};
window.__vvAiChat[P] = inst;
return inst;
};
// ── Stored conversations list ───────────────────────────────────────────
// Rendered wherever it is wanted and pointed at a chat instance. Both surfaces show the same
// rows off the same store, so opening a thread on Monitor and finishing it on the AI tab is
// one conversation rather than two.
window.VvAiChatList = function (o) {
const box = document.getElementById(o.into);
if (!box) return null;
let rows = [], activeId = '';
function render() {
if (!rows.length) {
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return;
}
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c =>
`<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
+ `<span class="vv-ai-crow-t" title="${esc(c.title)}">${esc(c.title)}</span>`
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
+ `</div>`).join('') + '</div>';
}
function load() {
fetch(API + '?action=chats').then(r => r.json())
.then(d => { if (d.ok) { rows = d.chats || []; render(); } })
.catch(() => {});
}
box.addEventListener('click', e => {
const del = e.target.closest('[data-del]');
if (del) {
e.stopPropagation();
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'chat_delete', id: del.dataset.del }) })
.then(() => {
// Deleting the conversation you are in leaves the transcript on screen but detached
// from the store, which would silently resurrect it on the next turn.
if (del.dataset.del === activeId && o.chat) o.chat.newChat();
load();
}).catch(() => {});
return;
}
const row = e.target.closest('.vv-ai-crow');
if (row && o.chat) { activeId = row.dataset.id; render(); o.chat.loadChat(row.dataset.id); }
});
return {
reload: load,
setActive(id) { activeId = id; load(); },
};
};
})();
</script>
<?php
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
// profile which profile button starts active
// compact card-sized chrome, for a chat living inside a Monitor card
// height transcript height; a fixed value in compact mode, since a card in a grid row
// cannot grow with its content without dragging the row's other cards with it
// empty empty-state text
// controls extra markup dropped into the composer's control strip
function vv_ai_chat_markup(string $prefix, array $o = []): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
$compact = !empty($o['compact']);
$height = $o['height'] ?? '';
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"' : '';
?>
<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">
<?php foreach (['varaverk' => 'Varaverk Assistant', 'chat' => 'General Chat',
'code' => 'Code Sketcher'] as $key => $label): ?>
<button class="vv-ai-prof" data-prof="<?= $key ?>" type="button"><?= $label ?></button>
<?php endforeach; ?>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
<div class="vv-ai-composer">
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
<div class="vv-ai-ctrls">
<?= $o['controls'] ?? '' ?>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<span class="vv-ai-hint">Ctrl+Enter to send</span>
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
</div>
</div>
</div>
<?php
}
// 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.
function vv_ai_chat_list_markup(string $prefix): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
?>
<div id="<?= $p ?>-chats"><div class="vv-ai-none">loading…</div></div>
<?php
}
+55 -394
View File
@@ -56,9 +56,11 @@
// source viewer overlay
//
// DEPENDS ON
// api/ai.php stats / ask / poll / clear
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
// api/ai.php stats / tokens / bugs / ask / poll / clear / chats
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_chat.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
@@ -81,56 +83,9 @@ if (is_dir('/var/log/varaverk')) {
.vv-ai-stat-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
.vv-ai-stat-v { font-size:15px; font-weight:bold; color:#c8c8c8; font-family:monospace; }
.vv-ai-stat-s { font-size:10px; color:#5a5a5a; }
.vv-ai-ok { color:#6fcf97 !important; }
.vv-ai-warn { color:#ffb74d !important; }
.vv-ai-bad { color:#e57 !important; }
/* ── Profiles ───────────────────────────────────────────────────────────── */
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; }
/* ── Chat ───────────────────────────────────────────────────────────────── */
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
.vv-ai-msg { margin-bottom:16px; }
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
overflow-x:auto; margin:8px 0; }
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
.vv-ai-cite:hover { text-decoration:underline; }
.vv-ai-danger { background:#1f0d0d; border:1px solid #4a1f1f; border-left:3px solid #e57;
border-radius:4px; padding:8px 10px; margin-bottom:9px; font-size:11px;
line-height:1.55; color:#d99; }
.vv-ai-danger strong { color:#f88; }
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
.vv-ai-think-t:hover { color:#777; border-color:#333; }
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; }
.vv-ai-think.open { display:block; }
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; margin-bottom:4px; }
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
.vv-ai-src-i:hover { color:#8a8a8a; }
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
/* Profiles, transcript, composer and the source overlay are styled by include/ai_chat.php,
which the Monitor tab's AI row shares. Only what is unique to this tab lives below. */
/* ── Health + loaded models ─────────────────────────────────────────────── */
/* The split lines the System checks card up with the right edge of the third banner stat.
@@ -199,18 +154,6 @@ if (is_dir('/var/log/varaverk')) {
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
/* ── Composer ───────────────────────────────────────────────────────────── */
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
border-radius:6px; padding:10px; background:#0e0e0e; }
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
/* ── Settings card ──────────────────────────────────────────────────────── */
/* Collapsed by default and by markup, not by JS: the card is closed because the class is
simply absent, so it cannot flash open on a slow load or stick open if a script throws.
@@ -231,27 +174,8 @@ if (is_dir('/var/log/varaverk')) {
.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-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
border-radius:3px; cursor:pointer; }
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* ── Source overlay ─────────────────────────────────────────────────────── */
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
padding:36px; }
#vv-ai-view.open { display:block; }
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
display:flex; flex-direction:column; }
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
color:#9a9a9a; white-space:pre; }
</style>
<?php vv_ai_chat_assets(); ?>
<div id="vv-ai-wrap">
@@ -295,31 +219,34 @@ if (is_dir('/var/log/varaverk')) {
</div>
</div>
<div class="vv-ai-profiles">
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
<button class="vv-ai-prof" data-prof="code" type="button">Code Sketcher</button>
<span class="vv-ai-prof-hint" id="vv-ai-prof-hint"></span>
</div>
<div class="vv-ai-chat" id="vv-ai-chat">
<div class="vv-ai-empty">
Ask Varaverk about itself.<br>
Answers come only from this installation's own documentation, with sources.
<!-- Saved conversations. Above the transcript rather than beside it: this column is already
narrow at the width the diag row wants, and a sidebar here would take that width from the
thing being read. The same rows render on the Monitor tab off the same store. -->
<div class="vv-ai-tok">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-chead">
<span class="vv-ai-diag-h" style="margin:0">Saved conversations</span>
<span class="vv-ai-set-sum" style="margin-left:auto">newest first · oldest drop off automatically</span>
</div>
<?php vv_ai_chat_list_markup('vv-ai'); ?>
</div>
</div>
<div class="vv-ai-composer">
<textarea class="vv-ai-input" id="vv-ai-input" rows="2"
placeholder="e.g. what stops rsync and the mover running at once?"></textarea>
<div class="vv-ai-ctrls">
<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>
<button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
<span class="vv-ai-hint">Ctrl+Enter to send · 3-turn history · build <?=$_vv_ai_build?>
<span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>
<button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
</div>
</div>
<?php
vv_ai_chat_markup('vv-ai', [
'profile' => 'varaverk',
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
. 'documentation, with sources.',
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
// The build stamp and the liveness marker stay on this tab. Unraid swaps tab content by
// AJAX without tearing down the previous page's JavaScript, so "is the browser running the
// code I just deployed" is not answerable from the server — the marker answers it from the
// browser, and it is where every deploy on this tab gets checked.
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>'
. '<span class="vv-ai-hint">build ' . $_vv_ai_build
. ' <span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>',
]);
?>
<!-- Settings. Below the composer and closed by default: these change how the next answer is
built, not what it is asked, so they should be reachable without being in the way. The
@@ -376,42 +303,19 @@ if (is_dir('/var/log/varaverk')) {
</div>
</div>
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
<div class="vv-ai-view-box">
<div class="vv-ai-view-h">
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
</div>
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
</div>
</div>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
const API = '/plugins/varaverk/api/ai.php';
// Server-side is the authority on retrieval and history depth; these are for the UI only.
// Always starts on varaverk — the strict profile is the one you land on, so a misuse costs a
// "the docs don't cover that" rather than an invented claim about the system.
const PROFILES = {
varaverk: { turns: 3, kind: true,
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
chat: { turns: 8, kind: false,
hint: 'Ordinary conversation. Knows your memory notes, but not the docs — it\'ll point you back here for specifics.' },
code: { turns: 4, kind: false,
hint: 'Drafts short scripts for Custom Scripts. First drafts — it flags flags it isn\'t sure of. Test before trusting.' },
};
let profile = 'varaverk';
let history = []; // {role, content} — trimmed per profile
let busy = false;
let lastSources = [];
// 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
// only here: the banner, the token ledger, filed bug reports and the memory editor.
let chat = null, chatList = null;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// ── Banner ──────────────────────────────────────────────────────────────
function stat(label, value, sub, cls) {
@@ -714,240 +618,6 @@ if (is_dir('/var/log/varaverk')) {
renderTokens();
});
// ── Minimal markdown, applied strictly after escaping ───────────────────
function fmt(text) {
let h = esc(text);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" onclick="vvAiCite($1)">[$1]</span>');
return h;
}
// ── Transcript ──────────────────────────────────────────────────────────
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
function chat() { return $('vv-ai-chat'); }
function scroll() { chat().scrollTop = chat().scrollHeight; }
function clearEmpty() { const e = chat().querySelector('.vv-ai-empty'); if (e) e.remove(); }
function addUser(text) {
clearEmpty();
chat().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
scroll();
}
let pendingTimer = null;
function addPending() {
const n = el(`<div class="vv-ai-msg bot" id="vv-ai-pending"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
+ `<span id="vv-ai-phase">starting…</span>`
+ `<span id="vv-ai-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
chat().appendChild(n); scroll();
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a stalled
// turn and a slow one look identical, and the slow case here is legitimately ~40s.
const t0 = Date.now();
clearInterval(pendingTimer);
pendingTimer = setInterval(() => {
const e = $('vv-ai-elapsed');
if (!e) { clearInterval(pendingTimer); return; }
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
}, 1000);
}
function phase(t) { const p = $('vv-ai-phase'); if (p) p.textContent = t; }
function sourcesHtml(sources) {
if (!sources || !sources.length) return '';
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
sources.forEach((s, i) => {
const label = [s.path, s.section, s.heading].filter(Boolean).join(' ');
h += `<div class="vv-ai-src-i" onclick="vvAiOpen('${esc(s.path)}')">`
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
});
return h + '</div>';
}
function addAnswer(job) {
const p = $('vv-ai-pending'); if (p) p.remove();
lastSources = job.sources || [];
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
if (job.thinking) {
h += `<div class="vv-ai-think-t" onclick="this.nextElementSibling.classList.toggle('open')">`
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
}
// Detected from the generated code, not from the model saying so. These scripts run as root
// on a schedule, so the banner is louder than the prose warning it may or may not have added.
if (job.warnings && job.warnings.length) {
h += `<div class="vv-ai-danger"><strong>Destructive — read before running.</strong> `
+ `This script ${job.warnings.map(esc).join('; ')}. `
+ `Run any dry-run form first and check the paths are what you expect.</div>`;
}
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
h += sourcesHtml(job.sources);
const t = job.timing || {};
if (t.tok_s) {
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
}
chat().appendChild(el(h + '</div>')); scroll();
}
function addError(msg) {
const p = $('vv-ai-pending'); if (p) p.remove();
chat().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
scroll();
}
// ── Ask / poll ──────────────────────────────────────────────────────────
function send() {
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
// loop that stopped, a tab left open across a deploy — would otherwise make every later
// click a no-op with the previous "starting…" still on screen, which reads as a hang that
// produces no request and therefore no server-side trace at all. That cost a diagnosis
// session; it now says so and offers the way out.
if (busy) {
addError('A previous question is still marked in-flight, so this one was not sent. '
+ 'Reload the tab to reset it.');
return;
}
const q = $('vv-ai-input').value.trim();
if (!q) return;
busy = true;
$('vv-ai-send').disabled = true;
addUser(q);
$('vv-ai-input').value = '';
addPending();
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
// installed elsewhere on the page — would escape the promise chain entirely and leave the
// pending indicator up forever with nothing logged anywhere. A hang is the one failure
// that tells you nothing, so every path below has to end in a visible message.
// URLSearchParams, not FormData. FormData sends multipart/form-data, and a multipart POST
// to this endpoint hangs with no status code ever returned — the request leaves the browser
// with a valid token and correct body, and never reaches PHP: no CSRF termination, no
// fatal, no entry log. Every other POST on this host that demonstrably works, including
// Unraid's own, is application/x-www-form-urlencoded. Same fields, same $_POST on the
// server; only the encoding changes.
let res;
try {
const body = new URLSearchParams({
action: 'ask',
profile: profile,
question: q,
history: JSON.stringify(history.slice(-PROFILES[profile].turns * 2)),
kind: PROFILES[profile].kind ? $('vv-ai-kind').value : '',
think: $('vv-ai-think').checked ? '1' : '0',
});
res = fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body,
});
} catch (e) {
addError('Could not send the request: ' + (e && e.message ? e.message : e)
+ ' — this failed in the browser before reaching the server.');
finish();
return;
}
res.then(r => r.text().then(t => ({ status: r.status, text: t })))
.then(({ status, text }) => {
if (!text.trim()) {
// The CSRF prepend terminates with an empty body, so this is the shape that failure
// takes. Naming it beats a bare JSON parse error.
addError('Empty response (HTTP ' + status + '). This usually means the request was '
+ 'rejected before the endpoint ran — check the CSRF token shim.');
finish(); return;
}
let d;
try { d = JSON.parse(text); }
catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160));
finish(); return; }
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
history.push({ role: 'user', content: q });
poll(d.token, Date.now());
})
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
}
function finish() {
busy = false;
$('vv-ai-send').disabled = false;
clearInterval(pendingTimer);
}
function poll(token, started) {
if (Date.now() - started > POLL_CEIL) {
addError('Timed out waiting for a response.'); finish(); return;
}
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
const j = d.job || {};
if (j.status === 'done') {
addAnswer(j);
history.push({ role: 'assistant', content: j.answer });
const cap = PROFILES[profile].turns * 2;
if (history.length > cap) history = history.slice(-cap);
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
finish(); loadBanner(); loadTokens(); return;
}
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
phase(j.status === 'generating'
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
setTimeout(() => poll(token, started), POLL_MS);
}).catch(e => { addError('Poll failed: ' + e); finish(); });
}
// ── Source viewer ───────────────────────────────────────────────────────
window.vvAiOpen = function (path) {
$('vv-ai-view-t').textContent = path;
$('vv-ai-view-b').textContent = 'Loading…';
$('vv-ai-view').classList.add('open');
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
.then(r => r.json())
.then(d => { $('vv-ai-view-b').textContent = d.ok ? d.content
: (d.error || 'Could not read this file.'); })
.catch(e => { $('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
};
window.vvAiCloseView = function () { $('vv-ai-view').classList.remove('open'); };
window.vvAiCite = function (n) {
const s = lastSources[n - 1];
if (s && s.path) vvAiOpen(s.path);
};
// ── Profiles ────────────────────────────────────────────────────────────
// Switching clears the conversation history sent to the model but leaves the transcript on
// screen. Carrying turns across a profile change would mean feeding cited, retrieval-grounded
// answers into a mode that has no retrieval — the model would keep referring to sources it can
// no longer see. The visible marker is so the transcript still reads honestly afterwards.
function setProfile(p) {
if (!PROFILES[p] || p === profile) return;
profile = p;
document.querySelectorAll('.vv-ai-prof').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
$('vv-ai-prof-hint').textContent = PROFILES[p].hint;
$('vv-ai-kind').style.display = PROFILES[p].kind ? '' : 'none';
if (history.length) {
const label = document.querySelector('.vv-ai-prof[data-prof="' + p + '"]').textContent;
chat().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(label)
+ ' — earlier turns are no longer carried</div>'));
scroll();
}
history = [];
$('vv-ai-input').focus();
}
document.querySelectorAll('.vv-ai-prof').forEach(b =>
b.addEventListener('click', () => setProfile(b.dataset.prof)));
$('vv-ai-prof-hint').textContent = PROFILES[profile].hint;
// ── Memory panel ────────────────────────────────────────────────────────
// Live character count against the cap, because the budget is the whole point: this text is
// prepended to every single turn and competes with retrieval for a 16k context.
@@ -993,31 +663,21 @@ if (is_dir('/var/log/varaverk')) {
$('vv-ai-mem-text').addEventListener('input', memCount);
// ── Wiring ──────────────────────────────────────────────────────────────
$('vv-ai-send').addEventListener('click', send);
$('vv-ai-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
// The full-size instance. onTurn is why the banner and the ledger stay current without either
// of them polling for it: the totals only move when a turn completes, and the chat is the
// thing that knows when that was.
chat = VvAiChat({
prefix: 'vv-ai',
profile: 'varaverk', // the strict profile is the one you land on
kindEl: 'vv-ai-kind',
thinkEl: 'vv-ai-think',
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
+ 'documentation, with sources.',
onTurn: () => { loadBanner(); loadTokens(); },
onChats: id => { if (chatList) chatList.setActive(id); },
});
$('vv-ai-clear').addEventListener('click', () => {
history = []; lastSources = [];
chat().innerHTML = '<div class="vv-ai-empty">Ask Varaverk about itself.<br>'
+ "Answers come only from this installation's own documentation, with sources.</div>";
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
// Surface any script error on this tab into the transcript. Without it a throw anywhere in
// the page is invisible unless the console happens to be open, which is how a silent hang
// survives a diagnosis session.
window.addEventListener('error', e => {
if (!busy) return;
addError('Script error: ' + (e.message || 'unknown')
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
finish();
});
window.addEventListener('unhandledrejection', e => {
if (!busy) return;
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
finish();
});
chatList = VvAiChatList({ into: 'vv-ai-chats', chat });
// Proves to the page itself that this copy of the script is the one running, and that its
// click handler is attached. If the marker still reads NOT RUNNING, the browser is executing
@@ -1026,14 +686,15 @@ if (is_dir('/var/log/varaverk')) {
if (live) { live.style.color = '#4a4a4a'; live.textContent = '· JS live'; }
// Old copies of this script survive a tab swap and keep their timers. Tearing the previous
// one down stops N banners polling in parallel, and stops a stale closure's busy flag from
// being the thing the user is actually looking at.
// one down stops N banners polling in parallel. The chat instance tears itself down the same
// way, keyed on its prefix, so it is not handled here.
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
const bannerTimer = setInterval(loadBanner, 30000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
window.__vvAiTeardown = function () { clearInterval(bannerTimer); };
loadBanner();
loadTokens();
loadBugs();
chatList.reload();
})();
</script>
+143 -2
View File
@@ -29,7 +29,15 @@
//
// Missing subsystems simply do not render.
// No GPU, no UPS, no VMs — the corresponding card is absent rather than showing zeros
// or an error. The page is built to be correct on hardware lacking any given part.
// or an error. The page is built to be correct on hardware lacking any given part. The
// AI row is the same rule applied to a subsystem rather than a device: it exists only
// where vv_ai_ui_on() is true, which is the AI host with AI_ENABLED set.
//
// The assistant here starts on General Chat, where the AI tab starts on Varaverk Assistant.
// Different jobs. The tab is where you go to interrogate the installation; this is the
// box you type an idle question into while watching the dashboard. The worker escalates
// anything genuinely about this install to the strict profile on its own, so starting
// loose costs nothing and starting strict would refuse ordinary questions.
//
// OPERATIONAL SAFEGUARDS
// The health roll-up must not default to healthy.
@@ -63,17 +71,22 @@
// RENDERS
// System header, CPU per core, memory breakdown, GPU cards, storage pools and array disks,
// network, UPS, VMs, containers, transcode sessions, media now-playing, watchdog summary,
// partner node cards
// partner node cards, and — on the AI host only — model residency, saved conversations and
// an assistant
//
// DEPENDS ON
// include/monitor.php required directly for initial render
// include/ai_chat.php the AI row's chat and conversation list, shared with the AI tab
// api/monitor.php full payload, slower cycle
// api/monitor_fast.php fast-moving values, 1s
// api/media.php now-playing sessions
// api/docker_action.php container actions
// api/flag_toggle.php toggles
// api/ai.php the AI row's turns and conversation store
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/monitor.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
if (vv_ai_ui_on()) vv_ai_chat_assets();
?>
<style>
@keyframes vvRsPulse {
@@ -279,6 +292,59 @@ require_once dirname(__DIR__) . '/include/monitor.php';
<div id="vv-array-body">Loading...</div>
</div>
<?php if (vv_ai_ui_on()): ?>
<!-- Row 5: AI residency | Saved conversations | Assistant -->
<!-- Present only on the AI host with AI_ENABLED true, on the same footing as the GPU and UPS
cards above: a subsystem that is not here does not render an empty card explaining that
it is not here. api/ai.php refuses every action independently, so this is presentation
rather than access control. -->
<div class="vv-card" id="vv-ai-stats-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="2.5" width="7" height="7" rx="1.2"/><circle cx="4.7" cy="5" r="0.7" fill="#888" stroke="none"/><circle cx="7.3" cy="5" r="0.7" fill="#888" stroke="none"/><line x1="4.5" y1="7.3" x2="7.5" y2="7.3"/><line x1="6" y1="2.5" x2="6" y2="0.8"/><line x1="2.5" y1="6" x2="0.8" y2="6"/><line x1="9.5" y1="6" x2="11.2" y2="6"/></svg></span>
AI
</span>
<a href="/plugins/varaverk/Varaverk.page?tab=ai" class="vv-card-cog" title="AI tab">⚙</a>
</h3>
<div id="vv-ai-stats-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ai-chats-card" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="13" height="12" viewBox="0 0 13 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M1 2.2C1 1.5 1.5 1 2.2 1H10.8C11.5 1 12 1.5 12 2.2V6.8C12 7.5 11.5 8 10.8 8H4.5L2 10.5V8H2.2C1.5 8 1 7.5 1 6.8Z"/></svg></span>
Conversations
</span>
</h3>
<?php vv_ai_chat_list_markup('vv-mon-ai'); ?>
</div>
<!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different
component and vvAiDockOn() there tests for that id by name. -->
<div class="vv-card" id="vv-ai-assistant-card" style="grid-column:span 5;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><path d="M4.4 4.6C4.4 3.7 5.1 3.1 6 3.1C6.9 3.1 7.6 3.7 7.6 4.5C7.6 5.9 6 5.7 6 7"/><circle cx="6" cy="8.8" r="0.6" fill="#888" stroke="none"/></svg></span>
Assistant
</span>
</h3>
<?php
// Starts on General Chat, unlike the AI tab. This is the box you type an idle question
// into while watching the dashboard, and the worker escalates anything about this install
// to the assistant on its own — so landing on the strict profile here would refuse
// ordinary questions to guard against a mistake the server already prevents.
vv_ai_chat_markup('vv-mon-ai', [
'profile' => 'chat',
'compact' => true,
'height' => '300px',
'empty' => 'Ask anything. Questions about this installation are handed to the '
. 'Varaverk assistant automatically.',
'placeholder' => 'Ask the assistant…',
]);
?>
</div>
<?php endif; ?>
</div>
@@ -1817,10 +1883,63 @@ function vvPollMonitor(live) {
dfData.vms = d.vms ?? { available: false, vms: [] };
vvRenderDockerFolders(dfData);
// ── AI ──────────────────────────────────────────────────────────────────
if (d.ai) vvRenderAi(d.ai);
})
.catch(vvPollFailed);
}
// ── AI residency card ────────────────────────────────────────────────────────
// Leads with offload, not with size. 100% on this card is the difference between ~62 tok/s and
// roughly a quarter of that, and nothing else in the WebGUI surfaces it — a model that has
// quietly fallen back to partial CPU offload is otherwise invisible until answers feel slow.
function vvRenderAi(ai) {
const box = document.getElementById('vv-ai-stats-body');
if (!box) return;
const rt = ai.runtime ?? {}, ix = ai.index ?? {}, tok = ai.tokens ?? null;
const line = (label, value, cls) =>
`<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:6px;">`
+ `<span style="font-size:11px;color:#666;">${vvEscHtml(label)}</span>`
+ `<span style="font-size:11px;font-family:monospace;${cls || 'color:#999;'}">${vvEscHtml(value)}</span></div>`;
let h = '';
if (!rt.reachable) {
h += line('Ollama', 'unreachable', 'color:#e57;');
} else if (!rt.loaded) {
h += line('Model', 'not loaded', 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#444;margin:-2px 0 7px;">loads on first question</div>`;
} else {
const p = rt.offload_pct;
const full = p === 100;
h += line('GPU offload', p === null ? '—' : p + '%', full ? 'color:#6fcf97;' : 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#444;margin:-2px 0 7px;">`
+ (full ? 'all on GPU' : 'layers on CPU — slow') + `</div>`;
if (rt.context) h += line('Context', rt.context.toLocaleString());
}
if (rt.gpu) {
h += line('VRAM', (rt.gpu.mem_used/1024).toFixed(1) + '/' + (rt.gpu.mem_total/1024).toFixed(1) + ' GB');
h += line('GPU util', rt.gpu.util + '%');
}
// Staleness is stated, not implied. An index older than the newest tracked file answers
// confidently out of code that has since changed — the one failure a grounded answer cannot
// reveal on its own.
if (!ix.exists) {
h += line('Index', 'not built', 'color:#e57;');
} else {
h += line('Index', ix.chunks.toLocaleString() + ' chunks',
ix.stale ? 'color:#ffb74d;' : 'color:#999;');
if (ix.stale) h += `<div style="font-size:10px;color:#8a6a3a;margin:-2px 0 7px;">source newer — reindex</div>`;
}
if (tok && tok.turns) h += line('Today', tok.total.toLocaleString() + ' tok');
box.innerHTML = h;
}
// 5s against a payload the cache writer refreshes once a minute. Polling faster cannot make the
// data newer — it only decides how soon the page notices the writer's update.
vvPollRunner(vvPollMonitor, 5000);
@@ -2481,4 +2600,26 @@ document.addEventListener('click', () => {
// on this page ever called the function — there are no such buttons, on this or any other tab.
// Removed rather than left as an unreachable handler for the platform's three most destructive
// operations. The endpoint stays; see its header for why it is kept unwired.
// ── AI row ───────────────────────────────────────────────────────────────────
// Constructed only when the row rendered. The card markup is behind vv_ai_ui_on(), so on any
// other host these ids do not exist and the factories are never called — the row is absent
// rather than broken.
//
// Same store as the AI tab, so a conversation started here is the one you carry on there. The
// instance keys itself on its prefix and tears down any predecessor, which matters on this tab
// specifically: Unraid swaps tab content by AJAX without unloading the previous page's script,
// and this page already has three poll loops that survive that.
if (document.getElementById('vv-mon-ai-chat')) {
let vvMonChatList = null;
const vvMonChat = VvAiChat({
prefix: 'vv-mon-ai',
profile: 'chat',
empty: 'Ask anything. Questions about this installation are handed to the Varaverk '
+ 'assistant automatically.',
onChats: id => { if (vvMonChatList) vvMonChatList.setActive(id); },
});
vvMonChatList = VvAiChatList({ into: 'vv-mon-ai-chats', chat: vvMonChat });
vvMonChatList.reload();
}
</script>