Compare commits
3
Commits
df3c4bb369
...
ea0c713c89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0c713c89 | ||
|
|
4d00e37ca1 | ||
|
|
1e324636e0 |
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
|
||||
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
|
||||
}
|
||||
+53
-392
@@ -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>
|
||||
<!-- 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>
|
||||
|
||||
<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.
|
||||
<?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
|
||||
|
||||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
|
||||
// ── 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user