From 0621f668895cc3598082d2d7091b3c6c198b8f52 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 20 Aug 2026 19:53:28 -0400 Subject: [PATCH] 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. --- AI/ai_index.sh | 21 + Plugin/unraid/Tools/ai_rpc.php | 98 +++++ Plugin/unraid/Varaverk.page | 23 +- Plugin/unraid/api/ai.php | 559 +++------------------------ Plugin/unraid/include/ai_actions.php | 542 ++++++++++++++++++++++++++ Plugin/unraid/include/ai_repair.php | 28 +- Plugin/unraid/include/ai_rpc.php | 188 +++++++++ Plugin/unraid/include/config.php | 26 +- Plugin/unraid/pages/ai.php | 12 +- Plugin/unraid/pages/settings.php | 44 ++- git_pull_execute.sh | 13 +- 11 files changed, 1013 insertions(+), 541 deletions(-) create mode 100644 Plugin/unraid/Tools/ai_rpc.php create mode 100644 Plugin/unraid/include/ai_actions.php create mode 100644 Plugin/unraid/include/ai_rpc.php diff --git a/AI/ai_index.sh b/AI/ai_index.sh index c2ec233..93391ad 100755 --- a/AI/ai_index.sh +++ b/AI/ai_index.sh @@ -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 diff --git a/Plugin/unraid/Tools/ai_rpc.php b/Plugin/unraid/Tools/ai_rpc.php new file mode 100644 index 0000000..52b32ae --- /dev/null +++ b/Plugin/unraid/Tools/ai_rpc.php @@ -0,0 +1,98 @@ + 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); diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index 380e5b0..ebf07bf 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -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'; diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index ae400dc..5e9cb87 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -1,8 +1,14 @@ 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 true, 'token' => $token]); - exit; -} - -echo json_encode(['ok' => false, 'error' => 'Unknown action']); +if ($httpStatus !== 200) http_response_code($httpStatus); +echo json_encode($body); diff --git a/Plugin/unraid/include/ai_actions.php b/Plugin/unraid/include/ai_actions.php new file mode 100644 index 0000000..a57a47f --- /dev/null +++ b/Plugin/unraid/include/ai_actions.php @@ -0,0 +1,542 @@ + 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 true, 'token' => $token]; + } + + return ['ok' => false, 'error' => 'Unknown action']; +} diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 3f2d0b9..45684fa 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -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, diff --git a/Plugin/unraid/include/ai_rpc.php b/Plugin/unraid/include/ai_rpc.php new file mode 100644 index 0000000..95d8321 --- /dev/null +++ b/Plugin/unraid/include/ai_rpc.php @@ -0,0 +1,188 @@ + 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; +} diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index 5a9b1a5..86448f8 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -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 diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 9e2af99..df54f52 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -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. diff --git a/Plugin/unraid/pages/settings.php b/Plugin/unraid/pages/settings.php index a7639e6..f2ba172 100644 --- a/Plugin/unraid/pages/settings.php +++ b/Plugin/unraid/pages/settings.php @@ -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(); -
AI
@@ -181,24 +188,45 @@ vv_conf_ui_assets(); onclick="vvSetAiToggle(this)">
AI features
-
Master switch — the AI tab, the Scheduler assistant, and the AI tools on the Tools card
+
Master switch — +
+ +
+ Model + + + +
+
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.
- 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 + is not drawn, the AI tools are not listed, and api/ai.php refuses every request. This card is the way back on. +
+ + This node owns the model. 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. + + The model, the index and the shared memory live on + , 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. +
-
diff --git a/git_pull_execute.sh b/git_pull_execute.sh index 1298001..7b12f6c 100755 --- a/git_pull_execute.sh +++ b/git_pull_execute.sh @@ -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