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(); -
api/ai.php
refuses every request. This card is the way back on.
+