Share one AI across the mesh instead of confining it to the owner

Curated state copied to every node is state that can disagree, so the index,
the model and the shared memory stay on the owner and each node reaches them
over the SSH trust onboarding already builds. Chats stay on the node that had
them; memory and bug reports stay the owner's to write.
This commit is contained in:
Gmer4Lfe
2026-08-20 19:53:28 -04:00
parent 4accd6b67e
commit 0621f66889
11 changed files with 1013 additions and 541 deletions
+21
View File
@@ -194,6 +194,19 @@ if [[ "${AI_ENABLED:-false}" != "true" ]]; then
exit 0
fi
# The mesh shares one AI, and the index belongs to the node that holds the model. A mirror has the
# same checkout and could build one, but nothing there would read it: retrieval happens wherever
# generation happens, which is the owner.
#
# A skip, not an error. This is reached from git_pull_execute.sh on every node after every pull;
# before the AI became mesh-wide it ran here too and failed on the empty OLLAMA_URL, nightly and
# silently, because the caller discards its output.
_ai_owner="${AI_OWNER_HOST:-host1}"
if [[ "${MY_ID,,}" != "${_ai_owner,,}" ]]; then
log "This node is not the AI owner ($_ai_owner) — the index lives there; skipping"
exit 0
fi
if [[ "$DRY_RUN" == false && "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
@@ -204,6 +217,14 @@ acquire_lock
command -v node >/dev/null 2>&1 || { error "node not found — required to build the index"; exit 1; }
[[ -f "$CLI" ]] || { error "missing $CLI"; exit 1; }
# The AI owner has had data/ai since the subsystem was built, so nothing ever created it — cli.js
# opens the DB by path and does not make the directory. On a first build the failure surfaces as a
# sqlite open error rather than as the missing directory it is.
if [[ "$DRY_RUN" == false ]] && ! mkdir -p "$(dirname "$DB")"; then
error "Cannot create $(dirname "$DB")"
exit 1
fi
if [[ -z "$OLLAMA_URL" ]]; then
error "${MY_ID}_OLLAMA_URL is empty — no local Ollama to index against"
exit 1
+98
View File
@@ -0,0 +1,98 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The mesh entry point for the AI subsystem. Another node forwards an action here over SSH; this
// runs it through the same vv_ai_dispatch() a browser request on this node would have used, and
// writes the response back as JSON on stdout.
//
// CLI only. It is never served over HTTP and takes nothing from the environment but stdin.
//
// OPERATIONAL MODEL
// stdin {"action":"ask","params":{…},"is_post":true}
// stdout {"_vv_rpc":1,"status":200,"body":{…}}
//
// The wrapper exists so an HTTP status can travel with the body — a 405 raised in the dispatcher
// has to arrive at the calling node as a 405, not as a 200 carrying an error string.
//
// DESIGN PRINCIPLES
// One implementation, two doors.
// This shares every handler with api/ai.php through include/ai_actions.php. A mesh request
// and a browser request cannot diverge in behaviour because there is only one behaviour.
//
// The caller's node id is a label, not a claim.
// params._vv_node says which node asked, so findings and incidents are stored against the
// node they describe. It is not consulted for authorization — possession of the partnership
// SSH key already settled that, at onboard.
//
// OPERATIONAL SAFEGUARDS
// Refuses to run anywhere but the AI owner.
// A mirror that somehow received a forwarded request must not answer it from its own empty
// stores. Exits non-zero so the caller reports a transport failure rather than rendering an
// empty success.
//
// Refuses to run over the web.
// Guarded on PHP_SAPI. Reachable over HTTP this would be an unauthenticated bypass of every
// gate api/ai.php applies, since it takes its whole request from stdin.
//
// The master switch is honoured here too.
// AI_ENABLED false on the owner means the mesh gets the same refusal a local request gets.
// The calling node checks its own switch; this one checks the owner's.
//
// Input is size-capped before it is decoded.
// stdin is an untrusted stream from another process. A malformed or endless payload must
// fail as a bad request, not as an out-of-memory.
//
// DEPENDS ON
// include/ai_actions.php vv_ai_dispatch() — the shared handlers
// include/config.php vv_ai_is_owner()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
require_once dirname(__DIR__) . '/include/ai_actions.php';
function vv_rpc_out(int $status, array $body): void {
echo json_encode(['_vv_rpc' => 1, 'status' => $status, 'body' => $body],
JSON_UNESCAPED_SLASHES), "\n";
}
// Not this node's job. Exit non-zero: the caller must see a transport failure, not an answer
// assembled from stores that are empty here by design.
if (!vv_ai_is_owner()) {
fwrite(STDERR, "ai_rpc: this node is not the AI owner\n");
exit(2);
}
// 1 MiB. A turn's history is the largest legitimate payload and is capped far below this by the
// per-message truncation in the dispatcher; anything larger is not a request this serves.
$raw = stream_get_contents(STDIN, 1024 * 1024);
$req = json_decode((string)$raw, true);
if (!is_array($req)) {
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: unreadable request']);
exit(0);
}
$action = trim((string)($req['action'] ?? ''));
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
$isPost = (bool)($req['is_post'] ?? false);
if ($action === '') {
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: no action']);
exit(0);
}
// The owner's own switch. The calling node already checked its own; this is the other half, and
// it is what makes turning AI off here take it off the whole mesh.
if (!vv_ai_enabled()) {
vv_rpc_out(200, ['ok' => false, 'error' => 'AI_ENABLED is false on the AI owner — AI features are off']);
exit(0);
}
vv_ai_log(sprintf('rpc action=%s from=%s', $action, (string)($params['_vv_node'] ?? '?')));
$httpStatus = 200;
$body = vv_ai_dispatch($action, $params, $isPost, $httpStatus);
vv_rpc_out($httpStatus, $body);
+12 -11
View File
@@ -430,18 +430,19 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
$tab = $_GET['tab'] ?? 'monitor';
$validTabs = ['monitor', 'scheduler', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
// The AI tab exists only on HOST1, and only while AI_ENABLED is true. Appended to $validTabs
// rather than filtered out of it, so the check below rejects ?tab=ai server-side as well —
// omitting the link is presentation, not access control, and api/ai.php refuses every action
// on the same two conditions independently.
// The AI tab exists only on the AI owner, and only while AI_ENABLED is true. Appended to
// $validTabs rather than filtered out of it, so the check below rejects ?tab=ai server-side as
// well — omitting the link is presentation, not access control, and api/ai.php refuses the
// owner-only actions independently.
//
// The host half is not a preference: HOST1 owns the GPU, the Ollama process and the index, and
// include/ai.php only ever reads the *local* {HOST}_OLLAMA_URL — there is no Tailscale resolver
// in the PHP layer the way there is in the shell. On any other host the tab could only render
// and then fail its own health check.
// The tab is the owner-only surface — it carries the bug reports, the index and the model
// configuration. Assistant docks elsewhere use vv_ai_ui_on(), which every node with a reachable
// model passes.
// The host half is a deliberate split, not a technical limit. The mesh shares one AI: every node
// reaches the owner's model through include/ai_rpc.php, so an assistant works everywhere. What
// does not travel is this tab — it carries the bug reports, the index and the model configuration,
// the surface where a wrong answer is expensive and the vocabulary assumes you built the thing.
// Someone running two containers on a node they were handed gets the assistant, not the machinery
// behind it.
//
// Assistant docks elsewhere use vv_ai_ui_on(), which every node in the mesh passes.
$_vv_ai = vv_ai_owner_ui_on();
if ($_vv_ai) $validTabs[] = 'ai';
+57 -502
View File
@@ -1,8 +1,14 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// AI tab endpoint. Serves the status banner, starts a chat turn, and reports its progress —
// the token-and-poll contract behind the AI page.
// Browser entry point for the AI subsystem. Serves the status banner, starts a chat turn, and
// reports its progress — the token-and-poll contract behind the AI page and every assistant
// dock in the plugin.
//
// Transport only. The actions themselves live in include/ai_actions.php, which this shares with
// Tools/ai_rpc.php — the mesh entry point another node reaches over SSH. This file owns the
// things that are true of a browser request and nothing else: the CSRF-covered method split,
// the request trace, the master switch, and the routing decision.
//
// OPERATIONAL MODEL
// Generation takes 25-76 seconds on this hardware, so a turn is not answered in the request
@@ -30,34 +36,25 @@
// its token, so the token is not guessable.
//
// OPERATIONAL SAFEGUARDS
// Every action is refused off HOST1, and ask is refused when AI is disabled.
// AI_ENABLED gates the whole subsystem and vv_is_ai_host() gates the node; the tab is
// hidden when either fails, but hiding a link is not access control and the endpoint is
// reachable directly. The host check sits ahead of the dispatch and answers 404.
// The per-action safeguards live with the actions.
// Token minting, hex path validation, per-message history validation, scope whitelisting
// and the detached spawn are all in include/ai_actions.php, documented there, and apply
// identically to a browser request and a mesh request. Restating them here would be two
// copies to keep in step and one of them always losing.
//
// Every token is validated as hex before it composes a path.
// vv_ai_job_path() returns null for anything else, and each caller checks. That pattern
// is what confines reads and deletes to the job directory.
// AI_ENABLED is checked before routing, not after.
// It is this node's own switch. A mirror with AI off must not forward to the owner —
// honouring the toggle locally while quietly using someone else's model is not what the
// switch says it does.
//
// 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. 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.
// Refusal is a 404, not a redirect or an empty 200.
// The owner-only actions answer 404 off the owner. Omitting a link is presentation; this
// endpoint is reachable directly, so the gate is enforced server-side too.
//
// 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
// consume the context budget the retrieved chunks need.
//
// The worker is spawned detached with output discarded.
// nohup, stdin from /dev/null, stdout and stderr to /dev/null — the job file is the only
// channel. A worker holding the request's file descriptors would keep the connection
// open, defeating the point of returning a token.
//
// Stale job files are reaped on each ask.
// /tmp is tmpfs so they vanish on reboot, but a long-lived host would accumulate one
// file per question asked. Anything older than an hour is removed.
// A remote failure is reported as a remote failure.
// vv_ai_rpc() returns the owner's own JSON when it gets one and a named transport error
// when it does not. Neither is silently turned into an empty success — a mirror that
// cannot reach the owner must say so rather than render an empty banner.
//
// REQUEST
// GET ?action=stats banner payload
@@ -85,9 +82,9 @@
// {"ok":false,"error":…}
//
// DEPENDS ON
// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
// include/ai_repair.php the findings store — loaded only by the two actions that read it
// Tools/ai_chat_worker.php the detached worker
// include/ai_actions.php vv_ai_dispatch() — every action, shared with the mesh entry point
// include/ai_rpc.php vv_ai_route(), vv_ai_rpc() — where an action runs, and the SSH hop
// include/ai.php vv_ai_enabled(), reached through ai_actions.php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// First executable statement, deliberately dependency-free. A request that is rejected by the
// CSRF prepend never reaches here and a request that dies inside the include never reaches the
@@ -103,496 +100,54 @@ if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) {
. ' len=' . ($_SERVER['CONTENT_LENGTH'] ?? '-') . "\n", FILE_APPEND | LOCK_EX);
}
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';
// History depth is per profile and still decided server-side rather than by the page — the page
// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
// Reasoning is not stored in history, so it does not compound.
const VV_AI_MAX_QUESTION = 4000; // characters
const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message
const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped
require_once dirname(__DIR__) . '/include/ai_actions.php';
require_once dirname(__DIR__) . '/include/ai_rpc.php';
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats'));
// Request trace. There is no nginx access log on this host and the CSRF prepend exits with an
// empty body, so without this there is no way to tell "the request never arrived" from "the
// request arrived and failed" — which is exactly the ambiguity that made the first hang
// undiagnosable. Excludes poll, which would otherwise write a line per second per open tab.
function vv_ai_log(string $msg): void {
if (!is_dir('/var/log/varaverk')) return;
@file_put_contents('/var/log/varaverk/ai.log',
date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}
// Params merged rather than picked by method. The POST-only checks inside the dispatcher are what
// enforce the CSRF contract; which superglobal a value arrived in is not a security property, and
// merging means a handler that reads one key does not care how the request was shaped.
$params = $_POST + $_GET;
if ($action !== 'poll') {
vv_ai_log(sprintf('%s action=%s from=%s',
$_SERVER['REQUEST_METHOD'] ?? '?', $action ?: '(none)',
$_SERVER['REMOTE_ADDR'] ?? '?'));
}
// Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab
// on any host but HOST1, but a hidden link is not access control and this endpoint is reachable
// directly. Every action is refused rather than just the expensive ones — there is no such thing
// as a read this host is entitled to, since the index and the model are not here.
if (!vv_ai_is_owner()) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']);
exit;
}
// Master switch, on the same footing as the host gate rather than only in front of ask. With
// AI_ENABLED false the tab is not in the tab list and the scheduler dock is not rendered, so
// nothing in the UI can legitimately reach any action here — including the cheap reads, which
// would otherwise still answer with index and token figures for a subsystem the operator has
// turned off. Not a 404: the switch is a setting, and the message names the setting.
// Master switch, ahead of everything. With AI_ENABLED false the tab is not in the tab list and no
// dock is rendered, so nothing in the UI can legitimately reach any action here — including the
// cheap reads, which would otherwise still answer with index and token figures for a subsystem the
// operator has turned off. Not a 404: the switch is a setting, and the message names the setting.
//
// Checked before the routing below because it is this node's own switch either way. A mirror with
// AI off must not forward to the owner: the operator turned AI off on this box, and honouring that
// locally while quietly using someone else's model is not what the switch says it does.
if (!vv_ai_enabled()) {
echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']);
exit;
}
// ── stats ─────────────────────────────────────────────────────────────────────
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
// every open tab and used to pay a full collection each time — around a second, most of it spent
// waiting on Ollama and nvidia-smi — for numbers that only change when the writer next runs.
//
// ?live=1 bypasses it, for the case where something was just changed and the point is to see the
// result. A missing cache always falls back to collecting, so the cache can never be the reason
// the banner fails to render.
if ($action === 'stats') {
echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]);
// Routing. vv_ai_route() decides local, remote or refused for this action on this node; the three
// outcomes and the reasoning behind each live in include/ai_rpc.php, next to the transport that
// carries them, rather than being restated here.
$httpStatus = 200;
$route = vv_ai_route($action);
if ($route === VV_AI_ROUTE_DENY) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'This AI surface lives on the owner node only']);
exit;
}
// ── tokens ────────────────────────────────────────────────────────────────────
// Separate from stats rather than folded into it. stats is polled every 30 seconds by every
// open tab; this reads a file that grows without bound between prunes. The totals only move
// when a turn completes, and the page knows exactly when that happened, so it asks then.
if ($action === 'tokens') {
echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]);
exit;
}
$body = $route === VV_AI_ROUTE_REMOTE
? vv_ai_rpc($action, $params, $isPost, $httpStatus)
: vv_ai_dispatch($action, $params, $isPost, $httpStatus);
// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
$token = trim($_GET['token'] ?? '');
if (vv_ai_job_path($token) === null) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$job = vv_ai_job_read($token);
if ($job === null) {
// The worker writes its first state after this request may already have arrived.
echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit;
}
echo json_encode(['ok' => true, 'job' => $job]);
exit;
}
// ── memory ────────────────────────────────────────────────────────────────────
if ($action === 'memory_get') {
$m = vv_ai_memory_read();
echo json_encode(['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'],
'max' => vv_ai_memory_max(), 'exists' => $m['exists'],
'path' => vv_ai_memory_path()]);
exit;
}
if ($action === 'memory_set') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$r = vv_ai_memory_write((string)($_POST['memory'] ?? ''));
vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error']));
echo json_encode($r + ['max' => vv_ai_memory_max()]);
exit;
}
// ── learned-memory proposals ──────────────────────────────────────────────────
// The store the assistant files candidates into. Accepting is the only path by which model-written
// text reaches a prompt, and it is a POST so the CSRF prepend covers it.
if ($action === 'mem_proposals') {
require_once __DIR__ . '/../include/ai_memory_learn.php';
$m = vv_ai_memory_read('learned');
echo json_encode([
'ok' => true,
'enabled' => vv_ai_mem_learn_enabled(),
'auto' => vv_ai_mem_learn_auto(),
// The list states the gate as well as the rows: an empty list means "nothing proposed"
// when learning is on and "nothing is looking" when it is off, and those are different.
'open' => vv_ai_mem_list('open'),
'recent' => array_slice(vv_ai_mem_list(), 0, 25),
'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()],
]);
exit;
}
if ($action === 'mem_proposal_action') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
require_once __DIR__ . '/../include/ai_memory_learn.php';
$id = trim($_POST['id'] ?? '');
$act = trim($_POST['act'] ?? '');
$r = vv_ai_mem_action($id, $act);
vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act,
$r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?'))));
echo json_encode($r);
exit;
}
// ── stop ──────────────────────────────────────────────────────────────────────
// Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for this
// exact job — never a process group. Signalling a group is what took the WebGUI down on
// 2026-08-07, and no group kill is needed here: the worker is a single php process whose only
// child-like thing is an HTTP connection to Ollama, which dies with it.
//
// Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the
// operator has seen enough, not because they want it discarded.
if ($action === 'stop') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$token = trim($_POST['token'] ?? '');
if (vv_ai_job_path($token) === null) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$job = vv_ai_job_read($token);
if ($job === null) { echo json_encode(['ok' => false, 'error' => 'No such job']); exit; }
$status = (string)($job['status'] ?? '');
if ($status === 'done' || $status === 'error' || $status === 'stopped') {
echo json_encode(['ok' => true, 'already' => true, 'status' => $status]); exit;
}
$pid = (int)($job['pid'] ?? 0);
// Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal.
$killed = false;
if ($pid >= 2) {
// Pid reuse is the reason for this: the recorded worker may have exited seconds ago and
// the number been handed to something else entirely. The cmdline must name both this
// worker and this job's own file before anything is signalled.
$cmdline = @file_get_contents("/proc/$pid/cmdline");
$cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline);
if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) {
$killed = @posix_kill($pid, SIGTERM);
// No escalation ladder. The worker holds no lock and writes the job file atomically,
// so there is no cleanup that a delay would protect — and a SIGKILL race could land
// between the temp write and the rename.
}
}
// The job file is rewritten either way. If the pid could not be verified the worker is
// already gone, and the page still needs a terminal state instead of polling to its ceiling.
$job['status'] = 'stopped';
$job['stopped'] = true;
$job['answer'] = trim((string)($job['partial'] ?? $job['answer'] ?? ''));
unset($job['partial']);
@file_put_contents(vv_ai_job_path($token), json_encode($job));
vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars',
substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer'])));
echo json_encode(['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])]);
exit;
}
// ── clear ─────────────────────────────────────────────────────────────────────
if ($action === 'clear') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$p = vv_ai_job_path(trim($_POST['token'] ?? ''));
if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; }
if (file_exists($p)) @unlink($p);
echo json_encode(['ok' => true]);
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 (!vv_ai_profile_ok($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 = vv_ai_profiles_max_turns() * 2;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
// Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name from
// a page's own view state, it is stored and later replayed into a prompt, and anything
// richer than a file name is an instruction-injection surface for no benefit.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope);
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.
if ($action === 'bugs') {
echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]);
exit;
}
if ($action === 'bug_close') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1');
echo json_encode(['ok' => $ok]);
exit;
}
// The report, rendered server-side. Read-only by design: what the operator reviews is byte for
// byte what gets sent, so approving one text and transmitting another is not possible. It is also
// the only renderer — the page used to build its own markdown, which is two formats to keep in
// step and one of them always losing.
if ($action === 'bug_report') {
$id = trim($_GET['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }
$t = vv_ai_bug_targets();
$title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? '');
echo json_encode([
'ok' => true,
'title' => $title,
'markdown' => vv_ai_bug_report($bug),
'targets' => $t,
// Built here because the repo name lives here. Length is the caller's problem to notice:
// GitHub truncates a very long query rather than refusing it, which would silently send a
// half report — so the page checks and falls back to the copy box.
'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title='
. rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)),
]);
exit;
}
// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure: the
// two destinations are different people, and a silent substitution is how a report meant for a
// private backlog ends up public.
if ($action === 'bug_send_local') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$id = trim($_POST['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }
// Re-rendered from the store rather than taken from the request. The browser showed this text
// read-only; accepting a body from the page would make that guarantee decorative.
$r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''),
vv_ai_bug_report($bug));
vv_ai_log(sprintf('bug_send_local id=%s %s', $id,
$r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?')));
echo json_encode($r);
exit;
}
// ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled
// in here rather than at the top of the file: it is the largest include in the plugin and poll
// runs once a second per open tab, so it is loaded by the two actions that need it and by nothing
// else.
//
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being
// true when it goes off, and answering them — including saying "this was never a problem" — is
// exactly what an operator turning the feature off is likely to want to do first. The gate states
// are reported instead, so the card can say what is running rather than the endpoint pretending
// the store is empty.
if ($action === 'findings' || $action === 'finding_action') {
require_once dirname(__DIR__) . '/include/ai_repair.php';
if ($action === 'findings') {
// Closed findings are the history — what was dismissed, what a fix actually fixed — and
// they are asked for explicitly rather than shipped with every poll of the open list.
$rows = [];
$open = 0; $needs = 0;
foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
$state = (string)($f['state'] ?? 'open');
if ($state === 'open') $open++;
elseif ($state === 'needs_operator') $needs++;
// The three things the page must not decide for itself: which actions this row
// offers, and what its state and kind mean in words.
$f['actions'] = vv_ai_finding_actions($f);
$f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
$f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
$rows[] = $f;
}
echo json_encode(['ok' => true,
'repair' => ['enabled' => vv_ai_repair_enabled(),
'autofix' => vv_ai_repair_autofix_enabled(),
'last' => vv_ai_sweep_last()],
'findings' => $rows,
'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]);
exit;
}
// POST, because fix writes conf through the guarded path and every other answer writes state.
// Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this
// endpoint's — a tab left open overnight is holding buttons the store has moved past.
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$fid = trim($_POST['id'] ?? '');
$act = trim($_POST['act'] ?? '');
$r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? ''));
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
echo json_encode($r);
exit;
}
// ── incident_add ──────────────────────────────────────────────────────────────
// Appends one operator-written "this was the fix" note against a scope. POST only, and the
// scope is whitelisted the same way ask's is — it is written to a file that later rides in a
// prompt, so it gets the same treatment as anything else that reaches the model.
if ($action === 'incident_add') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
echo json_encode(vv_ai_incident_add(
trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? '')));
exit;
}
// ── ask ───────────────────────────────────────────────────────────────────────
if ($action === 'ask') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$cfg = vv_ai_config();
if ($cfg['model'] === '') {
echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit;
}
$question = trim($_POST['question'] ?? '');
if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; }
if (mb_strlen($question) > VV_AI_MAX_QUESTION) {
echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit;
}
$profile = trim($_POST['profile'] ?? 'varaverk');
if (!vv_ai_profile_ok($profile)) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
}
$maxTurns = vv_ai_profile_turns($profile);
// Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
// The scheduler page sends it so a question can say "this setting" and mean something; the
// AI tab sends nothing and the worker simply omits the location line.
//
// Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything
// richer than a file name is an instruction-injection surface for no benefit — a scope is
// only ever a name from this page's own view state.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
// The retrieval filter only means anything to the profile that retrieves.
$kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($_POST['kind'] ?? '') : '';
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit;
}
// Validate per message rather than trusting the blob: a crafted history could otherwise
// inject a system role, or push the context past the offload ceiling.
$clean = [];
$hist = json_decode($_POST['history'] ?? '[]', true);
if (is_array($hist)) {
foreach ($hist 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)];
}
}
if (count($clean) > $maxTurns * 2) {
$clean = array_slice($clean, -($maxTurns * 2));
}
$dir = vv_ai_job_dir();
foreach (glob($dir . '/*.json') ?: [] as $old) {
if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old);
}
$token = bin2hex(random_bytes(16));
$jobFile = vv_ai_job_path($token);
$worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php';
if (!file_exists($worker)) {
echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit;
}
// Not suppressed: if the job file cannot be written the worker has nowhere to report and
// the page polls a token that will never resolve — which looks exactly like a hang.
if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) {
vv_ai_log('ask FAILED — cannot write ' . $jobFile);
echo json_encode(['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR]);
exit;
}
// setsid, not just nohup. nohup detaches from the terminal but leaves the child in the
// caller's process group — php-fpm's. That is the arrangement that took the WebGUI down on
// 2026-08-07 when a Stop signalled a group it did not own. Stop below signals one verified
// pid and never a group, so this is belt and braces, but it also means a php-fpm restart no
// longer takes a running generation with it.
$cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' '
. escapeshellarg($jobFile) . ' '
. escapeshellarg($question) . ' '
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
. escapeshellarg($profile) . ' '
. escapeshellarg($scope) . ' '
// Asked for per turn. Only meaningful on a profile holding web_search — the worker
// checks that, so a crafted web=1 against any other profile changes nothing.
. escapeshellarg(($_POST['web'] ?? '') === '1' ? '1' : '0')
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
// Redacted before it is logged, for the same reason the stored transcript is: asking the
// assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-',
mb_substr(vv_ai_redact($question), 0, 80)));
echo json_encode(['ok' => true, 'token' => $token]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
if ($httpStatus !== 200) http_response_code($httpStatus);
echo json_encode($body);
+542
View File
@@ -0,0 +1,542 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Every AI action, in one dispatcher, independent of how the request arrived. api/ai.php calls
// it for browser requests; Tools/ai_rpc.php calls it for mesh requests forwarded from another
// node over SSH. Both get identical behaviour because there is only one implementation.
//
// DESIGN PRINCIPLES
// Transport-free. Nothing here reads a superglobal, sets a header, echoes, or exits. Handlers
// take a params array and return the response body as an array. That is what makes the
// same code serveable over HTTP and over SSH — the handlers cannot tell the difference,
// so the two paths cannot drift.
//
// The dispatcher owns behaviour; the caller owns access.
// Authentication, CSRF and the node/action gates live in the caller. This file assumes
// the request is already allowed. Two callers with different trust models share one set
// of handlers precisely because the handlers do not re-litigate trust.
//
// Extracted, not rewritten.
// This was the body of api/ai.php. The transformation is mechanical — `echo json_encode(X);
// exit` became `return X`, a 405 became $httpStatus plus a return, and superglobals became
// $p. Every validation, every whitelist and every comment is the original. A refactor of a
// file this security-sensitive earns nothing by also being a redesign.
//
// OPERATIONAL SAFEGUARDS
// Tokens are minted here and never accepted from elsewhere.
// random_bytes, hex, fixed length. A job's answer is readable by anyone who can guess its
// token, so the token is not guessable.
//
// Every token is validated as hex before it composes a path.
// vv_ai_job_path() returns null for anything else, and each caller checks. That pattern is
// what confines reads and deletes to the job directory.
//
// 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. chat_save applies the same validation, because a stored chat
// is replayed into a later prompt.
//
// Scope is whitelisted, not escaped.
// It reaches the model as text, so anything richer than a file name is an
// instruction-injection surface for no benefit.
//
// stop signals one verified pid, never a process group.
// The cmdline must name both the worker and this job's own token before anything is
// signalled, because pid reuse is real. Group signalling took the WebGUI down on
// 2026-08-07.
//
// EXPORTS
// vv_ai_dispatch() action → response body
// vv_ai_log() the shared request/action trace at /var/log/varaverk/ai.log
//
// DEPENDS ON
// include/ai.php config, stats, memory, chats, bugs, job files
// include/ai_memory_learn.php loaded per action — learned-memory proposals
// include/ai_repair.php loaded per action — the largest include in the plugin
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/ai.php';
// History depth is per profile and still decided server-side rather than by the page — the page
// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
// Reasoning is not stored in history, so it does not compound.
const VV_AI_MAX_QUESTION = 4000; // characters
const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message
const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped
// Request trace. There is no nginx access log on this host and the CSRF prepend exits with an
// empty body, so without this there is no way to tell "the request never arrived" from "the
// request arrived and failed" — which is exactly the ambiguity that made the first hang
// undiagnosable. Callers exclude poll, which would otherwise write a line per second per open tab.
function vv_ai_log(string $msg): void {
if (!is_dir('/var/log/varaverk')) return;
@file_put_contents('/var/log/varaverk/ai.log',
date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}
// Run one action. $p carries the request parameters regardless of how they arrived; $isPost is
// still passed separately because the POST-only checks are a CSRF guarantee, not a parameter.
//
// $httpStatus is by reference and named for what it is — `$status` is taken, by stop's own local
// for a job's state, and shadowing that would be a silent bug in the one handler that kills a
// process.
function vv_ai_dispatch(string $action, array $p, bool $isPost, int &$httpStatus = 200): array {
$postOnly = function () use (&$httpStatus): array {
$httpStatus = 405;
return ['ok' => false, 'error' => 'POST only'];
};
// ── stats ─────────────────────────────────────────────────────────────────────
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
// every open tab and used to pay a full collection each time — around a second, most of it
// spent waiting on Ollama and nvidia-smi — for numbers that only change when the writer runs.
//
// live=1 bypasses it, for the case where something was just changed and the point is to see
// the result. A missing cache always falls back to collecting, so the cache can never be the
// reason the banner fails to render.
if ($action === 'stats') {
return ['ok' => true, 'stats' => vv_ai_stats_cached(isset($p['live']))];
}
// ── tokens ────────────────────────────────────────────────────────────────────
// Separate from stats rather than folded into it. stats is polled every 30 seconds by every
// open tab; this reads a file that grows without bound between prunes. The totals only move
// when a turn completes, and the page knows exactly when that happened, so it asks then.
if ($action === 'tokens') {
return ['ok' => true, 'tokens' => vv_ai_token_stats()];
}
// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
$token = trim($p['token'] ?? '');
if (vv_ai_job_path($token) === null) {
return ['ok' => false, 'error' => 'Invalid token'];
}
$job = vv_ai_job_read($token);
if ($job === null) {
// The worker writes its first state after this request may already have arrived.
return ['ok' => true, 'job' => ['status' => 'pending']];
}
return ['ok' => true, 'job' => $job];
}
// ── memory ────────────────────────────────────────────────────────────────────
if ($action === 'memory_get') {
$m = vv_ai_memory_read();
return ['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'],
'max' => vv_ai_memory_max(), 'exists' => $m['exists'],
'path' => vv_ai_memory_path()];
}
if ($action === 'memory_set') {
if (!$isPost) return $postOnly();
$r = vv_ai_memory_write((string)($p['memory'] ?? ''));
vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error']));
return $r + ['max' => vv_ai_memory_max()];
}
// ── learned-memory proposals ──────────────────────────────────────────────────
// The store the assistant files candidates into. Accepting is the only path by which
// model-written text reaches a prompt, and it is a POST so the CSRF prepend covers it.
if ($action === 'mem_proposals') {
require_once __DIR__ . '/ai_memory_learn.php';
$m = vv_ai_memory_read('learned');
return [
'ok' => true,
'enabled' => vv_ai_mem_learn_enabled(),
'auto' => vv_ai_mem_learn_auto(),
// The list states the gate as well as the rows: an empty list means "nothing proposed"
// when learning is on and "nothing is looking" when it is off, and those are different.
'open' => vv_ai_mem_list('open'),
'recent' => array_slice(vv_ai_mem_list(), 0, 25),
'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()],
];
}
if ($action === 'mem_proposal_action') {
if (!$isPost) return $postOnly();
require_once __DIR__ . '/ai_memory_learn.php';
$id = trim($p['id'] ?? '');
$act = trim($p['act'] ?? '');
$r = vv_ai_mem_action($id, $act);
vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act,
$r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?'))));
return $r;
}
// ── stop ──────────────────────────────────────────────────────────────────────
// Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for
// this exact job — never a process group. Signalling a group is what took the WebGUI down on
// 2026-08-07, and no group kill is needed here: the worker is a single php process whose only
// child-like thing is an HTTP connection to Ollama, which dies with it.
//
// Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the
// operator has seen enough, not because they want it discarded.
if ($action === 'stop') {
if (!$isPost) return $postOnly();
$token = trim($p['token'] ?? '');
if (vv_ai_job_path($token) === null) {
return ['ok' => false, 'error' => 'Invalid token'];
}
$job = vv_ai_job_read($token);
if ($job === null) return ['ok' => false, 'error' => 'No such job'];
$status = (string)($job['status'] ?? '');
if ($status === 'done' || $status === 'error' || $status === 'stopped') {
return ['ok' => true, 'already' => true, 'status' => $status];
}
$pid = (int)($job['pid'] ?? 0);
// Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal.
$killed = false;
if ($pid >= 2) {
// Pid reuse is the reason for this: the recorded worker may have exited seconds ago
// and the number been handed to something else entirely. The cmdline must name both
// this worker and this job's own file before anything is signalled.
$cmdline = @file_get_contents("/proc/$pid/cmdline");
$cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline);
if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) {
$killed = @posix_kill($pid, SIGTERM);
// No escalation ladder. The worker holds no lock and writes the job file
// atomically, so there is no cleanup that a delay would protect — and a SIGKILL
// race could land between the temp write and the rename.
}
}
// The job file is rewritten either way. If the pid could not be verified the worker is
// already gone, and the page still needs a terminal state instead of polling to its
// ceiling.
$job['status'] = 'stopped';
$job['stopped'] = true;
$job['answer'] = trim((string)($job['partial'] ?? $job['answer'] ?? ''));
unset($job['partial']);
@file_put_contents(vv_ai_job_path($token), json_encode($job));
vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars',
substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer'])));
return ['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])];
}
// ── clear ─────────────────────────────────────────────────────────────────────
if ($action === 'clear') {
if (!$isPost) return $postOnly();
$path = vv_ai_job_path(trim($p['token'] ?? ''));
if ($path === null) return ['ok' => false, 'error' => 'Invalid token'];
if (file_exists($path)) @unlink($path);
return ['ok' => true];
}
// ── 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') {
return ['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()];
}
if ($action === 'chat_get') {
$chat = vv_ai_chat_read(trim($p['id'] ?? ''));
if ($chat === null) return ['ok' => false, 'error' => 'No such chat'];
return ['ok' => true, 'chat' => $chat];
}
if ($action === 'chat_save') {
if (!$isPost) return $postOnly();
$profile = trim($p['profile'] ?? 'chat');
if (!vv_ai_profile_ok($profile)) {
return ['ok' => false, 'error' => 'Unknown profile: ' . $profile];
}
$clean = [];
$msgs = json_decode($p['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 = vv_ai_profiles_max_turns() * 2;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
// Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name
// from a page's own view state, it is stored and later replayed into a prompt, and
// anything richer than a file name is an instruction-injection surface for no benefit.
$scope = trim($p['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
$r = vv_ai_chat_save(trim($p['id'] ?? ''), $profile, $clean, $scope);
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
: 'FAILED: ' . $r['error']));
return $r;
}
if ($action === 'chat_delete') {
if (!$isPost) return $postOnly();
$ok = vv_ai_chat_delete(trim($p['id'] ?? ''));
return ['ok' => $ok, 'error' => $ok ? null : 'No such chat'];
}
// ── 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.
if ($action === 'bugs') {
return ['ok' => true, 'bugs' => vv_ai_bugs_list(($p['all'] ?? '') !== '1')];
}
if ($action === 'bug_close') {
if (!$isPost) return $postOnly();
$ok = vv_ai_bug_set_open(trim($p['id'] ?? ''), ($p['open'] ?? '0') === '1');
return ['ok' => $ok];
}
// The report, rendered server-side. Read-only by design: what the operator reviews is byte for
// byte what gets sent, so approving one text and transmitting another is not possible. It is
// also the only renderer — the page used to build its own markdown, which is two formats to
// keep in step and one of them always losing.
if ($action === 'bug_report') {
$id = trim($p['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) return ['ok' => false, 'error' => 'no such report'];
$t = vv_ai_bug_targets();
$title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? '');
return [
'ok' => true,
'title' => $title,
'markdown' => vv_ai_bug_report($bug),
'targets' => $t,
// Built here because the repo name lives here. Length is the caller's problem to
// notice: GitHub truncates a very long query rather than refusing it, which would
// silently send a half report — so the page checks and falls back to the copy box.
'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title='
. rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)),
];
}
// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure:
// the two destinations are different people, and a silent substitution is how a report meant
// for a private backlog ends up public.
if ($action === 'bug_send_local') {
if (!$isPost) return $postOnly();
$id = trim($p['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) return ['ok' => false, 'error' => 'no such report'];
// Re-rendered from the store rather than taken from the request. The browser showed this
// text read-only; accepting a body from the page would make that guarantee decorative.
$r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''),
vv_ai_bug_report($bug));
vv_ai_log(sprintf('bug_send_local id=%s %s', $id,
$r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?')));
return $r;
}
// ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is
// pulled in here rather than at the top of the file: it is the largest include in the plugin
// and poll runs once a second per open tab, so it is loaded by the two actions that need it
// and by nothing else.
//
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop
// being true when it goes off, and answering them — including saying "this was never a
// problem" — is exactly what an operator turning the feature off is likely to want to do
// first. The gate states are reported instead, so the card can say what is running rather
// than the endpoint pretending the store is empty.
if ($action === 'findings' || $action === 'finding_action') {
require_once __DIR__ . '/ai_repair.php';
if ($action === 'findings') {
// Closed findings are the history — what was dismissed, what a fix actually fixed —
// and they are asked for explicitly rather than shipped with every poll of the list.
$rows = [];
$open = 0; $needs = 0;
foreach (vv_ai_findings_list(($p['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
$state = (string)($f['state'] ?? 'open');
if ($state === 'open') $open++;
elseif ($state === 'needs_operator') $needs++;
// The three things the page must not decide for itself: which actions this row
// offers, and what its state and kind mean in words.
$f['actions'] = vv_ai_finding_actions($f);
$f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
$f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
$rows[] = $f;
}
return ['ok' => true,
'repair' => ['enabled' => vv_ai_repair_enabled(),
'autofix' => vv_ai_repair_autofix_enabled(),
'last' => vv_ai_sweep_last()],
'findings' => $rows,
'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]];
}
// POST, because fix writes conf through the guarded path and every other answer writes
// state. Which actions are legal for a given row is vv_ai_finding_apply_action()'s call,
// not this endpoint's — a tab left open overnight is holding buttons the store has moved
// past.
if (!$isPost) return $postOnly();
$fid = trim($p['id'] ?? '');
$act = trim($p['act'] ?? '');
$r = vv_ai_finding_apply_action($fid, $act, trim($p['note'] ?? ''));
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
return $r;
}
// ── finding_write ─────────────────────────────────────────────────────────────
// A sweep on another node filing what it found. Not reachable from a browser — vv_ai_route()
// never returns LOCAL for it off the owner and the page has no caller — it exists so that
// "sweep local, store central" needs no second store and no reconciliation.
//
// The host is taken from the transport's own view of who connected, never from the payload.
// The record decides which machine a fault is about and is what the finding id hashes on, so
// letting the body name it would let one node file findings as another.
if ($action === 'finding_write') {
if (!$isPost) return $postOnly();
require_once __DIR__ . '/ai_repair.php';
$f = json_decode((string)($p['finding'] ?? ''), true);
if (!is_array($f)) return ['ok' => false, 'error' => 'finding_write: unreadable finding'];
$node = trim((string)($p['_vv_node'] ?? ''));
if (!preg_match('/^host\d+$/', $node)) {
return ['ok' => false, 'error' => 'finding_write: caller did not identify a node'];
}
$f['host'] = $node;
$r = vv_ai_finding_write_local($f);
vv_ai_log(sprintf('finding_write from=%s kind=%s %s', $node, (string)($f['kind'] ?? '?'),
($r['ok'] ?? false) ? 'ok id=' . ($r['id'] ?? '?') : 'FAILED: ' . ($r['error'] ?? '?')));
return $r;
}
// ── incident_add ──────────────────────────────────────────────────────────────
// Appends one operator-written "this was the fix" note against a scope. POST only, and the
// scope is whitelisted the same way ask's is — it is written to a file that later rides in a
// prompt, so it gets the same treatment as anything else that reaches the model.
if ($action === 'incident_add') {
if (!$isPost) return $postOnly();
return vv_ai_incident_add(
trim($p['scope'] ?? ''), trim($p['symptom'] ?? ''), trim($p['fix'] ?? ''));
}
// ── ask ───────────────────────────────────────────────────────────────────────
if ($action === 'ask') {
if (!$isPost) return $postOnly();
$cfg = vv_ai_config();
if ($cfg['model'] === '') {
return ['ok' => false, 'error' => 'No generation model configured'];
}
$question = trim($p['question'] ?? '');
if ($question === '') return ['ok' => false, 'error' => 'question is required'];
if (mb_strlen($question) > VV_AI_MAX_QUESTION) {
return ['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters'];
}
$profile = trim($p['profile'] ?? 'varaverk');
if (!vv_ai_profile_ok($profile)) {
return ['ok' => false, 'error' => 'Unknown profile: ' . $profile];
}
$maxTurns = vv_ai_profile_turns($profile);
// Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
// The scheduler page sends it so a question can say "this setting" and mean something;
// the AI tab sends nothing and the worker simply omits the location line.
//
// Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything
// richer than a file name is an instruction-injection surface for no benefit — a scope is
// only ever a name from this page's own view state.
$scope = trim($p['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
// The retrieval filter only means anything to the profile that retrieves.
$kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($p['kind'] ?? '') : '';
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
return ['ok' => false, 'error' => 'Unknown kind: ' . $kind];
}
// Validate per message rather than trusting the blob: a crafted history could otherwise
// inject a system role, or push the context past the offload ceiling.
$clean = [];
$hist = json_decode($p['history'] ?? '[]', true);
if (is_array($hist)) {
foreach ($hist 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)];
}
}
if (count($clean) > $maxTurns * 2) {
$clean = array_slice($clean, -($maxTurns * 2));
}
$dir = vv_ai_job_dir();
foreach (glob($dir . '/*.json') ?: [] as $old) {
if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old);
}
$token = bin2hex(random_bytes(16));
$jobFile = vv_ai_job_path($token);
$worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php';
if (!file_exists($worker)) {
return ['ok' => false, 'error' => 'ai_chat_worker.php not found'];
}
// Not suppressed: if the job file cannot be written the worker has nowhere to report and
// the page polls a token that will never resolve — which looks exactly like a hang.
if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) {
vv_ai_log('ask FAILED — cannot write ' . $jobFile);
return ['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR];
}
// setsid, not just nohup. nohup detaches from the terminal but leaves the child in the
// caller's process group — php-fpm's. That is the arrangement that took the WebGUI down
// on 2026-08-07 when a Stop signalled a group it did not own. Stop above signals one
// verified pid and never a group, so this is belt and braces, but it also means a php-fpm
// restart no longer takes a running generation with it.
$cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' '
. escapeshellarg($jobFile) . ' '
. escapeshellarg($question) . ' '
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($p['think'] ?? '1') === '1' ? '1' : '0') . ' '
. escapeshellarg($profile) . ' '
. escapeshellarg($scope) . ' '
// Asked for per turn. Only meaningful on a profile holding web_search — the worker
// checks that, so a crafted web=1 against any other profile changes nothing.
. escapeshellarg(($p['web'] ?? '') === '1' ? '1' : '0')
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
// Redacted before it is logged, for the same reason the stored transcript is: asking the
// assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-',
mb_substr(vv_ai_redact($question), 0, 80)));
return ['ok' => true, 'token' => $token];
}
return ['ok' => false, 'error' => 'Unknown action'];
}
+24 -4
View File
@@ -310,9 +310,25 @@ function vv_ai_finding_get(string $id): ?array {
// Files a finding, or increments the one already describing this fault.
//
// Sweeps run on every node — each reads its own logs, which is the only place they exist — but the
// store is the AI owner's, so the operator answers one list instead of one per machine. A mirror
// therefore forwards; the owner writes. The split is here rather than in the sweep so that every
// caller files a finding the same way and none of them has to know where the store lives.
//
// $f expects: kind, subject, conf_key, conf_file, observed, evidence, source_log
// and optionally: proposed, proven, state, note
// and optionally: proposed, proven, state, note, host
function vv_ai_finding_write(array $f): array {
if (vv_ai_is_owner()) return vv_ai_finding_write_local($f);
require_once __DIR__ . '/ai_rpc.php';
$status = 200;
$r = vv_ai_rpc('finding_write', ['finding' => json_encode($f)], true, $status);
// Not swallowed. A sweep that cannot reach the owner has found something and failed to record
// it, and a caller told "ok" would move on and never retry.
return is_array($r) ? $r : ['ok' => false, 'error' => 'finding_write: no response from the AI owner'];
}
function vv_ai_finding_write_local(array $f): array {
$kind = (string)($f['kind'] ?? '');
$subject = trim((string)($f['subject'] ?? ''));
$confKey = trim((string)($f['conf_key'] ?? ''));
@@ -337,13 +353,18 @@ function vv_ai_finding_write(array $f): array {
if (!isset(VV_AI_FINDING_STATES[$state])) $state = 'open';
$now = time();
$id = vv_ai_finding_id($kind, $subject, $ref);
// Which machine this finding is about, resolved once and used for both the hash and the
// record. $f['host'] was accepted by the array below but the id was always hashed locally,
// so a collected partner finding hashed as ours — the exact collision the host-in-the-hash
// comment above exists to prevent.
$host = trim((string)($f['host'] ?? '')) ?: vv_detect_host();
$id = vv_ai_finding_id($kind, $subject, $ref, $host);
$rec = [
'id' => $id,
// Which machine this is about. Written even on a single-host install, because the store
// outlives the topology — a finding filed today is still on disk when the second node
// arrives, and one without a host is a record nobody can place.
'host' => (string)($f['host'] ?? vv_detect_host()),
'host' => $host,
'kind' => $kind,
'subject' => mb_substr($subject, 0, 120),
'conf_key' => $confKey,
@@ -369,7 +390,6 @@ function vv_ai_finding_write(array $f): array {
'severity' => vv_ai_finding_severity(['kind' => $kind, 'conf_key' => $confKey,
'arr_type' => (string)($f['arr_type'] ?? ''),
'sys_level' => (string)($f['sys_level'] ?? '')]),
'host' => vv_detect_host(),
'first' => $now,
'last' => $now,
'seen' => 1,
+188
View File
@@ -0,0 +1,188 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Where an AI action runs, and how it gets there. The mesh shares one AI: the owner node holds
// the model, the index and the shared memory, and every other node reaches them over SSH rather
// than keeping a second copy of any of it.
//
// Two exports. vv_ai_route() answers "local, remote, or refused" for one action on this node;
// vv_ai_rpc() carries a remote one to the owner and brings back its answer verbatim.
//
// OPERATIONAL MODEL
// Three routes, decided per action rather than per host:
//
// LOCAL this node answers. Everything on the owner. On a mirror, the chat store only —
// conversations are per-node by design, so they never leave the box they were had on.
// REMOTE forwarded to the owner: generation, retrieval, stats, the shared memory, findings.
// DENY refused with a 404. The curated writes — memory and bug reports — are the owner's.
//
// Transport is SSH over the trust partnership_onboard.sh already establishes, the same as
// node_chat and conf_sync: no listener, no new port, Tailscale-only for free. The request is
// JSON on stdin, the response is JSON on stdout, and Tools/ai_rpc.php on the far side hands both
// to the same vv_ai_dispatch() this node would have called locally.
//
// DESIGN PRINCIPLES
// Share, not copy.
// A mirror does not hold the index, the memory or the model and does not sync them. There
// is one of each, on the owner, and the mesh asks it. Copies of curated, hand-edited state
// are copies that can disagree, and reconciling them needs tombstones and an offline story
// — the same complexity node_chat's local-only delete deliberately refused.
//
// The job lives where the model lives.
// ask returns the owner's token and poll asks the owner about it, so the token-and-poll
// contract is unchanged; it simply resolves on another box. Nothing about the page changes.
//
// Chats stay home, memory is shared.
// A conversation is this operator's, on this node. What the assistant *knows* — the memory
// file, the learned notes, the phrasebook — is the owner's and is shared by everyone. The
// history for a turn travels in the request, so where chats are stored is independent of
// where generation happens.
//
// OPERATIONAL SAFEGUARDS
// The remote path is the WebGUI symlink, not a discovered one.
// /usr/local/emhttp/plugins/varaverk is what Unraid serves the plugin from on every node,
// whatever storage mode it uses. node_chat reads the partner's varaverk.cfg first because a
// delivery is occasional; poll runs once a second per open tab and cannot afford a second
// SSH round trip to find a path. If this symlink is wrong the whole plugin is already
// broken on that node, so it is not a weaker assumption than the one it replaces.
//
// One multiplexed connection, not one per call.
// ControlMaster with ControlPersist, socket in tmpfs. A fresh SSH handshake is 100-300ms;
// paying it per poll, per open tab, would make the assistant feel broken on a mirror.
//
// A transport failure is named, never rendered as an empty success.
// Unreachable owner, missing shim and unparseable output are three different errors and
// each says so. An empty banner that looks like "nothing to report" is the failure mode
// worth spending three messages on.
//
// Nothing here decides trust.
// Possession of the partnership SSH key is the authorization, established at onboard. This
// file routes; it does not authenticate.
//
// EXPORTS
// VV_AI_ROUTE_LOCAL / _REMOTE / _DENY
// vv_ai_route() action → route for this node
// vv_ai_rpc() forward one action to the owner, return its response body
//
// DEPENDS ON
// include/config.php vv_ai_owner_host(), vv_ai_is_owner(), vv_resolve_tailscale_ip()
// Tools/ai_rpc.php the far side — reached at the WebGUI symlink path
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
const VV_AI_ROUTE_LOCAL = 0;
const VV_AI_ROUTE_REMOTE = 1;
const VV_AI_ROUTE_DENY = 2;
// Curated on one node on purpose. Memory is the text that rides in every prompt; a bug report is
// what leaves this mesh for a tracker. Both are the owner's to write, and a second node keeping a
// divergent copy of either is the failure this refuses.
const VV_AI_OWNER_ONLY = ['memory_set', 'mem_proposal_action',
'bugs', 'bug_close', 'bug_report', 'bug_send_local'];
// Answered on the node that asked, even on a mirror. A conversation belongs to the operator in
// front of it, and the transcript for a turn travels in the request anyway.
const VV_AI_NODE_LOCAL = ['chats', 'chat_get', 'chat_save', 'chat_delete'];
// Reached over the mesh by a sweep on another node, never by a browser. Denied from the web on
// every node — on a mirror because it is not a page action, and on the owner because the only
// legitimate caller is the RPC shim, which does not come through here.
const VV_AI_RPC_ONLY = ['finding_write'];
function vv_ai_route(string $action): int {
if (in_array($action, VV_AI_RPC_ONLY, true)) return VV_AI_ROUTE_DENY;
if (vv_ai_is_owner()) return VV_AI_ROUTE_LOCAL;
if (in_array($action, VV_AI_NODE_LOCAL, true)) return VV_AI_ROUTE_LOCAL;
if (in_array($action, VV_AI_OWNER_ONLY, true)) return VV_AI_ROUTE_DENY;
return VV_AI_ROUTE_REMOTE;
}
// Where the multiplexed control socket lives. tmpfs is the right lifetime — a reboot should not
// inherit a stale socket — and the path is kept short because a unix socket path is capped near
// 108 characters and ssh composes this one with the user and host appended.
function vv_ai_rpc_socket_dir(): string {
$dir = rtrim(VV_CACHE_ROOT, '/') . '/ssh';
if (!is_dir($dir)) @mkdir($dir, 0700, true);
return $dir;
}
// Forward one action to the AI owner and return its response body.
//
// $httpStatus is set from the owner's own status when it reports one, so a 405 raised over there
// arrives here as a 405 rather than as a 200 carrying an error string.
function vv_ai_rpc(string $action, array $params, bool $isPost, int &$httpStatus = 200): array {
$vars = vv_conf_vars();
$me = strtoupper(vv_detect_host());
$owner = vv_ai_owner_host();
$sshKey = $vars[$me . '_SSH_KEY'] ?? '';
if (!$sshKey || !is_file($sshKey)) {
return ['ok' => false, 'error' => "No SSH key for this node ({$me}_SSH_KEY) — cannot reach the AI owner"];
}
$hostname = trim((string)($vars[strtoupper($owner)] ?? ''));
if ($hostname === '') {
return ['ok' => false, 'error' => "No hostname recorded for the AI owner ($owner)"];
}
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) {
return ['ok' => false, 'error' => "Cannot resolve $hostname on the tailnet — the AI owner is unreachable"];
}
// Which node is asking. Not an authorization claim — the SSH key already settled that — but a
// label, so findings and incidents filed from here are stored against the node they describe.
$params['_vv_node'] = strtolower(vv_detect_host());
$remote = '/usr/local/emhttp/plugins/varaverk/Tools/ai_rpc.php';
$sock = vv_ai_rpc_socket_dir() . '/ai-%h';
$cmd = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o BatchMode=yes -o StrictHostKeyChecking=no'
. ' -o ConnectTimeout=8'
. ' -o ControlMaster=auto -o ControlPersist=60s'
. ' -o ControlPath=' . escapeshellarg($sock)
. ' root@' . escapeshellarg($ip)
. ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote);
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$pr = @proc_open($cmd, $desc, $pipes);
if (!is_resource($pr)) {
return ['ok' => false, 'error' => 'Cannot start ssh to the AI owner'];
}
fwrite($pipes[0], json_encode([
'action' => $action,
'params' => $params,
'is_post' => $isPost,
], JSON_UNESCAPED_SLASHES));
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]); fclose($pipes[1]);
$err = stream_get_contents($pipes[2]); fclose($pipes[2]);
$rc = proc_close($pr);
// 127 is the guard above finding no shim — the owner is reachable but has not pulled a build
// that has one. Distinguished from a transport failure because the fix is entirely different.
if ($rc === 127) {
return ['ok' => false, 'error' => 'The AI owner has no Tools/ai_rpc.php — it needs a git pull'];
}
if ($rc !== 0) {
$detail = trim($err) !== '' ? ': ' . mb_substr(trim($err), 0, 200) : '';
return ['ok' => false, 'error' => "Cannot reach the AI owner ($hostname)$detail"];
}
$decoded = json_decode(trim($out), true);
if (!is_array($decoded)) {
return ['ok' => false, 'error' => 'The AI owner returned an unreadable response'];
}
// The shim wraps the body so a status can travel with it. An older owner that answers with a
// bare body still works — it simply carries no status, which is the 200 default.
if (isset($decoded['_vv_rpc'])) {
$httpStatus = (int)($decoded['status'] ?? 200);
return is_array($decoded['body'] ?? null) ? $decoded['body'] : ['ok' => false, 'error' => 'Malformed response from the AI owner'];
}
return $decoded;
}
+19 -7
View File
@@ -586,16 +586,28 @@ function vv_is_ai_host(): bool {
// its definition is missing is worse than no gate. Reads AI_ENABLED directly for the same
// reason. Fail-closed on anything but the literal "true", matching the conf's own contract.
// May this node show AI features — the assistant docks, the findings strips, the AI rows on the
// Monitor card. No longer "am I host1": a node without a GPU borrows the owner's model over the
// mesh, so every node in the mesh gets the assistant. What it does not get is the AI tab; see
// vv_ai_owner_ui_on().
// Monitor card. No longer "am I the owner": the mesh shares one AI, and every node reaches it
// through include/ai_rpc.php. What a mirror does not get is the AI tab; see vv_ai_owner_ui_on().
//
// Still fails closed. A node with no local URL and no owner URL resolves to nothing, and an
// assistant that cannot reach a model is worse than an absent one.
// The two halves are asymmetric on purpose. The owner still fails closed on its own URL, because
// a missing local URL there is a conf error nothing can work around. A mirror does not test
// reachability at all: it is one SSH round trip away from the answer, on a link that goes down
// and comes back, and hiding the entire assistant on a transient blip is worse than showing it
// and reporting the failure in place. vv_ai_rpc() names every transport failure precisely so this
// gate does not have to guess at one.
//
// It lives here rather than in include/ai.php because the pages that need it do not all load that
// file; the Scheduler loads only config.php, and a gate that silently answers false where its
// definition is missing is worse than no gate.
function vv_ai_ui_on(): bool {
if (strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) !== 'true') return false;
$host = strtoupper(vv_ai_model_host());
return trim((string)(vv_conf_vars()[$host . '_OLLAMA_URL'] ?? '')) !== '';
if (vv_ai_is_owner()) {
return trim((string)(vv_conf_vars()[strtoupper(vv_ai_owner_host()) . '_OLLAMA_URL'] ?? '')) !== '';
}
// A mirror needs somewhere to send the request. Without a hostname for the owner there is no
// round trip to attempt, and that is a conf gap rather than a transient one.
return trim((string)(vv_conf_vars()[strtoupper(vv_ai_owner_host())] ?? '')) !== '';
}
// May this node show the AI tab. Owner only, and deliberately so: that page carries the bug
+7 -5
View File
@@ -9,11 +9,13 @@
// api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
// the api layer on one response convention; see api/ai.php for why SSE was declined.
//
// The tab is only reachable on HOST1, and only when AI_ENABLED is true. Varaverk.page omits
// it from the tab list and rejects it server-side, and api/ai.php refuses every action on
// the same two conditions independently — hiding a link is not access control. HOST1 is the
// node with the GPU, the Ollama process and the index; include/ai.php reads only the local
// {HOST}_OLLAMA_URL, so the tab could not function anywhere else regardless.
// The tab is only reachable on the AI owner, and only when AI_ENABLED is true. Varaverk.page
// omits it from the tab list and rejects it server-side, and api/ai.php refuses the owner-only
// actions independently — hiding a link is not access control.
//
// That is a split of surfaces, not of capability. The mesh shares one AI and every node has an
// assistant, served from here over include/ai_rpc.php; what stays on this node is the machinery
// this page exposes — the index, the model configuration and the bug reports.
//
// DESIGN PRINCIPLES
// The banner leads with offload, not with size.
+36 -8
View File
@@ -42,12 +42,20 @@ $_enableLogging = ($_vars['ENABLE_LOGGING'] ?? 'false') === 'true';
$_discordKey = $_myId . '_DISCORD_WEBHOOK';
$_discordHook = $_vars[$_discordKey] ?? '';
// AI. The card is drawn only on the host that can actually run it — elsewhere AI_ENABLED is a
// switch with nothing behind it, since the model and the index are HOST1's. vv_ai_ui_on() is
// not the test here: it folds the switch into the host check, and this card is how the switch
// gets turned back on. Fail-closed on anything but "true", matching the gate it feeds.
// AI. Drawn on every node. AI_ENABLED is a per-node switch — master.conf is gitignored, so each
// host carries its own — and every node has an assistant behind it, served by the AI owner's
// model over the mesh.
//
// It used to be gated on vv_is_ai_host(), from when AI was genuinely owner-only and the switch
// had nothing behind it anywhere else. That left the only surface that can turn AI on missing
// from exactly the nodes that needed it.
//
// vv_ai_ui_on() is not the test: it folds the switch into the check, and this card is how the
// switch gets turned back on. Fail-closed on anything but "true", matching the gate it feeds.
$_aiHost = vv_is_ai_host();
$_aiEnabled = strtolower(trim($_vars['AI_ENABLED'] ?? 'false')) === 'true';
$_aiOwner = vv_ai_owner_host();
$_aiOwnerNm = trim((string)($_vars[strtoupper($_aiOwner)] ?? $_aiOwner));
// The shared settings renderer, for the All-settings card at the foot of the page.
require_once dirname(__DIR__) . '/include/confui.php';
@@ -171,7 +179,6 @@ vv_conf_ui_assets();
<span id="vv-ntf-fb" style="font-size:11px;"></span>
</div>
<?php if ($_aiHost): ?>
<!-- AI card -->
<div class="vv-set-card">
<div class="vv-set-hdr">AI</div>
@@ -181,24 +188,45 @@ vv_conf_ui_assets();
onclick="vvSetAiToggle(this)"></div>
<div>
<div class="vv-set-tog-lbl">AI features</div>
<div class="vv-set-tog-sub">Master switch — the AI tab, the Scheduler assistant, and the AI tools on the Tools card</div>
<div class="vv-set-tog-sub">Master switch —
<?= $_aiHost ? 'the AI tab, the assistant dock, and the AI tools on the Tools card'
: 'the assistant dock and the AI tools on the Tools card' ?></div>
</div>
</label>
<!-- Where the model lives. On a mirror this is the only place that says so — the AI tab,
which reports it in full, is owner-only. -->
<div class="vv-set-row" style="margin-bottom:10px;">
<span style="color:#666;font-size:11px;">Model</span>
<span style="font-size:11px;color:#4a7a4a;">
<?= $_aiHost ? 'runs on this node' : 'shared from ' . vvEscHtml($_aiOwnerNm) ?>
</span>
</div>
<div class="vv-set-info">
Varaverk works exactly as well with this off. Every feature that can lean on AI has a
complete non-AI path, so turning it off removes the AI surfaces from the WebGUI and
nothing else — no script changes behaviour, no schedule changes.
<br>
Off is total: with it off the AI tab is not in the tab bar, the Scheduler's assistant dock
Off is total: with it off
<?= $_aiHost ? 'the AI tab is not in the tab bar, the assistant dock' : 'the assistant dock' ?>
is not drawn, the AI tools are not listed, and
<code style="font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;">api/ai.php</code>
refuses every request. This card is the way back on.
<br>
<?php if ($_aiHost): ?>
<strong style="color:#a05a2c;">This node owns the model.</strong> The switch is still
per-node, but every other node is served from here — turning it off here takes AI off the
whole mesh, not just this box.
<?php else: ?>
The model, the index and the shared memory live on
<?= vvEscHtml($_aiOwnerNm) ?>, which answers for this node too. This switch is this node's
own: it controls whether the surfaces are drawn here, not whether they work anywhere else.
<?php endif; ?>
</div>
<span id="vv-set-ai-fb" style="font-size:11px;color:#444;"></span>
</div>
<?php endif; ?>
<!-- Unraid API Key card -->
<div class="vv-set-card">
+9 -4
View File
@@ -410,9 +410,13 @@ fi
# full confidence and correct-looking citations, so a day of drift means the assistant quoting
# code that no longer exists. That is why this runs here rather than being left to a human.
#
# Three gates, any of which skips it: the pull must have succeeded, AI_INDEX_ON_PULL must be
# true, and AI_ENABLED must be true. ai_index.sh also refuses on its own unless AI_ENABLED is
# exactly "true", so a node with AI off never pays for this even if the flags disagree.
# Four gates, any of which skips it: the pull must have succeeded, AI_INDEX_ON_PULL must be true,
# AI_ENABLED must be true, and this must be the AI owner. ai_index.sh also refuses on its own on
# both the switch and the owner check, so a node never pays for this even if the flags disagree.
#
# The owner gate is the newest and was missing: the mesh shares one AI and the index belongs with
# the model, but this ran on every node regardless. On a node without a local Ollama it failed
# every night on the empty URL and said nothing, because the call below discards its output.
#
# Never fatal. Indexing is an enhancement; a git pull must not be reported as failed because an
# embedding call timed out.
@@ -420,7 +424,8 @@ if [[ "$DRY_RUN" == true ]]; then
[[ "${AI_INDEX_ON_PULL:-false}" == "true" ]] && warn "DRY RUN — would refresh the AI index"
elif [[ "$SYNC_SUCCESS" == true \
&& "${AI_INDEX_ON_PULL:-false}" == "true" \
&& "${AI_ENABLED:-false}" == "true" ]]; then
&& "${AI_ENABLED:-false}" == "true" \
&& "${MY_ID,,}" == "${AI_OWNER_HOST:-host1}" ]]; then
_AI_INDEX="$TARGET_DIR/AI/ai_index.sh"
if [[ -f "$_AI_INDEX" ]]; then