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

Curated state copied to every node is state that can disagree, so the index,
the model and the shared memory stay on the owner and each node reaches them
over the SSH trust onboarding already builds. Chats stay on the node that had
them; memory and bug reports stay the owner's to write.
This commit is contained in:
Gmer4Lfe
2026-08-20 19:53:28 -04:00
parent 4accd6b67e
commit 0621f66889
11 changed files with 1013 additions and 541 deletions
+57 -502
View File
@@ -1,8 +1,14 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// AI tab endpoint. Serves the status banner, starts a chat turn, and reports its progress —
// the token-and-poll contract behind the AI page.
// Browser entry point for the AI subsystem. Serves the status banner, starts a chat turn, and
// reports its progress — the token-and-poll contract behind the AI page and every assistant
// dock in the plugin.
//
// Transport only. The actions themselves live in include/ai_actions.php, which this shares with
// Tools/ai_rpc.php — the mesh entry point another node reaches over SSH. This file owns the
// things that are true of a browser request and nothing else: the CSRF-covered method split,
// the request trace, the master switch, and the routing decision.
//
// OPERATIONAL MODEL
// Generation takes 25-76 seconds on this hardware, so a turn is not answered in the request
@@ -30,34 +36,25 @@
// its token, so the token is not guessable.
//
// OPERATIONAL SAFEGUARDS
// Every action is refused off HOST1, and ask is refused when AI is disabled.
// AI_ENABLED gates the whole subsystem and vv_is_ai_host() gates the node; the tab is
// hidden when either fails, but hiding a link is not access control and the endpoint is
// reachable directly. The host check sits ahead of the dispatch and answers 404.
// The per-action safeguards live with the actions.
// Token minting, hex path validation, per-message history validation, scope whitelisting
// and the detached spawn are all in include/ai_actions.php, documented there, and apply
// identically to a browser request and a mesh request. Restating them here would be two
// copies to keep in step and one of them always losing.
//
// Every token is validated as hex before it composes a path.
// vv_ai_job_path() returns null for anything else, and each caller checks. That pattern
// is what confines reads and deletes to the job directory.
// AI_ENABLED is checked before routing, not after.
// It is this node's own switch. A mirror with AI off must not forward to the owner —
// honouring the toggle locally while quietly using someone else's model is not what the
// switch says it does.
//
// History is validated per message, not trusted as a blob.
// Role must be user or assistant, content must be a non-empty string, and each is
// truncated. A crafted history could otherwise inject a system role or push the context
// past the offload ceiling. chat_save applies the same validation, because a stored
// conversation is replayed into a later prompt when it is reopened — an unchecked role
// written there is an injection that survives a reload rather than one turn.
// Refusal is a 404, not a redirect or an empty 200.
// The owner-only actions answer 404 off the owner. Omitting a link is presentation; this
// endpoint is reachable directly, so the gate is enforced server-side too.
//
// The question is length-capped before it reaches a command line.
// It is passed to the worker through escapeshellarg, but an unbounded string would still
// consume the context budget the retrieved chunks need.
//
// The worker is spawned detached with output discarded.
// nohup, stdin from /dev/null, stdout and stderr to /dev/null — the job file is the only
// channel. A worker holding the request's file descriptors would keep the connection
// open, defeating the point of returning a token.
//
// Stale job files are reaped on each ask.
// /tmp is tmpfs so they vanish on reboot, but a long-lived host would accumulate one
// file per question asked. Anything older than an hour is removed.
// A remote failure is reported as a remote failure.
// vv_ai_rpc() returns the owner's own JSON when it gets one and a named transport error
// when it does not. Neither is silently turned into an empty success — a mirror that
// cannot reach the owner must say so rather than render an empty banner.
//
// REQUEST
// GET ?action=stats banner payload
@@ -85,9 +82,9 @@
// {"ok":false,"error":…}
//
// DEPENDS ON
// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
// include/ai_repair.php the findings store — loaded only by the two actions that read it
// Tools/ai_chat_worker.php the detached worker
// include/ai_actions.php vv_ai_dispatch() — every action, shared with the mesh entry point
// include/ai_rpc.php vv_ai_route(), vv_ai_rpc() — where an action runs, and the SSH hop
// include/ai.php vv_ai_enabled(), reached through ai_actions.php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// First executable statement, deliberately dependency-free. A request that is rejected by the
// CSRF prepend never reaches here and a request that dies inside the include never reaches the
@@ -103,496 +100,54 @@ if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) {
. ' len=' . ($_SERVER['CONTENT_LENGTH'] ?? '-') . "\n", FILE_APPEND | LOCK_EX);
}
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';
// History depth is per profile and still decided server-side rather than by the page — the page
// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
// Reasoning is not stored in history, so it does not compound.
const VV_AI_MAX_QUESTION = 4000; // characters
const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message
const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped
require_once dirname(__DIR__) . '/include/ai_actions.php';
require_once dirname(__DIR__) . '/include/ai_rpc.php';
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats'));
// Request trace. There is no nginx access log on this host and the CSRF prepend exits with an
// empty body, so without this there is no way to tell "the request never arrived" from "the
// request arrived and failed" — which is exactly the ambiguity that made the first hang
// undiagnosable. Excludes poll, which would otherwise write a line per second per open tab.
function vv_ai_log(string $msg): void {
if (!is_dir('/var/log/varaverk')) return;
@file_put_contents('/var/log/varaverk/ai.log',
date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}
// Params merged rather than picked by method. The POST-only checks inside the dispatcher are what
// enforce the CSRF contract; which superglobal a value arrived in is not a security property, and
// merging means a handler that reads one key does not care how the request was shaped.
$params = $_POST + $_GET;
if ($action !== 'poll') {
vv_ai_log(sprintf('%s action=%s from=%s',
$_SERVER['REQUEST_METHOD'] ?? '?', $action ?: '(none)',
$_SERVER['REMOTE_ADDR'] ?? '?'));
}
// Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab
// on any host but HOST1, but a hidden link is not access control and this endpoint is reachable
// directly. Every action is refused rather than just the expensive ones — there is no such thing
// as a read this host is entitled to, since the index and the model are not here.
if (!vv_ai_is_owner()) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']);
exit;
}
// Master switch, on the same footing as the host gate rather than only in front of ask. With
// AI_ENABLED false the tab is not in the tab list and the scheduler dock is not rendered, so
// nothing in the UI can legitimately reach any action here — including the cheap reads, which
// would otherwise still answer with index and token figures for a subsystem the operator has
// turned off. Not a 404: the switch is a setting, and the message names the setting.
// Master switch, ahead of everything. With AI_ENABLED false the tab is not in the tab list and no
// dock is rendered, so nothing in the UI can legitimately reach any action here — including the
// cheap reads, which would otherwise still answer with index and token figures for a subsystem the
// operator has turned off. Not a 404: the switch is a setting, and the message names the setting.
//
// Checked before the routing below because it is this node's own switch either way. A mirror with
// AI off must not forward to the owner: the operator turned AI off on this box, and honouring that
// locally while quietly using someone else's model is not what the switch says it does.
if (!vv_ai_enabled()) {
echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']);
exit;
}
// ── stats ─────────────────────────────────────────────────────────────────────
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
// every open tab and used to pay a full collection each time — around a second, most of it spent
// waiting on Ollama and nvidia-smi — for numbers that only change when the writer next runs.
//
// ?live=1 bypasses it, for the case where something was just changed and the point is to see the
// result. A missing cache always falls back to collecting, so the cache can never be the reason
// the banner fails to render.
if ($action === 'stats') {
echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]);
// Routing. vv_ai_route() decides local, remote or refused for this action on this node; the three
// outcomes and the reasoning behind each live in include/ai_rpc.php, next to the transport that
// carries them, rather than being restated here.
$httpStatus = 200;
$route = vv_ai_route($action);
if ($route === VV_AI_ROUTE_DENY) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'This AI surface lives on the owner node only']);
exit;
}
// ── tokens ────────────────────────────────────────────────────────────────────
// Separate from stats rather than folded into it. stats is polled every 30 seconds by every
// open tab; this reads a file that grows without bound between prunes. The totals only move
// when a turn completes, and the page knows exactly when that happened, so it asks then.
if ($action === 'tokens') {
echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]);
exit;
}
$body = $route === VV_AI_ROUTE_REMOTE
? vv_ai_rpc($action, $params, $isPost, $httpStatus)
: vv_ai_dispatch($action, $params, $isPost, $httpStatus);
// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
$token = trim($_GET['token'] ?? '');
if (vv_ai_job_path($token) === null) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$job = vv_ai_job_read($token);
if ($job === null) {
// The worker writes its first state after this request may already have arrived.
echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit;
}
echo json_encode(['ok' => true, 'job' => $job]);
exit;
}
// ── memory ────────────────────────────────────────────────────────────────────
if ($action === 'memory_get') {
$m = vv_ai_memory_read();
echo json_encode(['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'],
'max' => vv_ai_memory_max(), 'exists' => $m['exists'],
'path' => vv_ai_memory_path()]);
exit;
}
if ($action === 'memory_set') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$r = vv_ai_memory_write((string)($_POST['memory'] ?? ''));
vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error']));
echo json_encode($r + ['max' => vv_ai_memory_max()]);
exit;
}
// ── learned-memory proposals ──────────────────────────────────────────────────
// The store the assistant files candidates into. Accepting is the only path by which model-written
// text reaches a prompt, and it is a POST so the CSRF prepend covers it.
if ($action === 'mem_proposals') {
require_once __DIR__ . '/../include/ai_memory_learn.php';
$m = vv_ai_memory_read('learned');
echo json_encode([
'ok' => true,
'enabled' => vv_ai_mem_learn_enabled(),
'auto' => vv_ai_mem_learn_auto(),
// The list states the gate as well as the rows: an empty list means "nothing proposed"
// when learning is on and "nothing is looking" when it is off, and those are different.
'open' => vv_ai_mem_list('open'),
'recent' => array_slice(vv_ai_mem_list(), 0, 25),
'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()],
]);
exit;
}
if ($action === 'mem_proposal_action') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
require_once __DIR__ . '/../include/ai_memory_learn.php';
$id = trim($_POST['id'] ?? '');
$act = trim($_POST['act'] ?? '');
$r = vv_ai_mem_action($id, $act);
vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act,
$r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?'))));
echo json_encode($r);
exit;
}
// ── stop ──────────────────────────────────────────────────────────────────────
// Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for this
// exact job — never a process group. Signalling a group is what took the WebGUI down on
// 2026-08-07, and no group kill is needed here: the worker is a single php process whose only
// child-like thing is an HTTP connection to Ollama, which dies with it.
//
// Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the
// operator has seen enough, not because they want it discarded.
if ($action === 'stop') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$token = trim($_POST['token'] ?? '');
if (vv_ai_job_path($token) === null) {
echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
}
$job = vv_ai_job_read($token);
if ($job === null) { echo json_encode(['ok' => false, 'error' => 'No such job']); exit; }
$status = (string)($job['status'] ?? '');
if ($status === 'done' || $status === 'error' || $status === 'stopped') {
echo json_encode(['ok' => true, 'already' => true, 'status' => $status]); exit;
}
$pid = (int)($job['pid'] ?? 0);
// Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal.
$killed = false;
if ($pid >= 2) {
// Pid reuse is the reason for this: the recorded worker may have exited seconds ago and
// the number been handed to something else entirely. The cmdline must name both this
// worker and this job's own file before anything is signalled.
$cmdline = @file_get_contents("/proc/$pid/cmdline");
$cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline);
if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) {
$killed = @posix_kill($pid, SIGTERM);
// No escalation ladder. The worker holds no lock and writes the job file atomically,
// so there is no cleanup that a delay would protect — and a SIGKILL race could land
// between the temp write and the rename.
}
}
// The job file is rewritten either way. If the pid could not be verified the worker is
// already gone, and the page still needs a terminal state instead of polling to its ceiling.
$job['status'] = 'stopped';
$job['stopped'] = true;
$job['answer'] = trim((string)($job['partial'] ?? $job['answer'] ?? ''));
unset($job['partial']);
@file_put_contents(vv_ai_job_path($token), json_encode($job));
vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars',
substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer'])));
echo json_encode(['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])]);
exit;
}
// ── clear ─────────────────────────────────────────────────────────────────────
if ($action === 'clear') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$p = vv_ai_job_path(trim($_POST['token'] ?? ''));
if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; }
if (file_exists($p)) @unlink($p);
echo json_encode(['ok' => true]);
exit;
}
// ── chats ─────────────────────────────────────────────────────────────────────
// Stored conversations. Listing and reading are GET because they change nothing; saving and
// deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here.
//
// Messages are validated per message on the way in, exactly as ask validates history and for
// the same reason: a stored chat is replayed into a later prompt when the operator reopens it,
// so a crafted role in the store would be an injection that survives a reload.
if ($action === 'chats') {
echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]);
exit;
}
if ($action === 'chat_get') {
$chat = vv_ai_chat_read(trim($_GET['id'] ?? ''));
if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; }
echo json_encode(['ok' => true, 'chat' => $chat]);
exit;
}
if ($action === 'chat_save') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$profile = trim($_POST['profile'] ?? 'chat');
if (!vv_ai_profile_ok($profile)) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
}
$clean = [];
$msgs = json_decode($_POST['messages'] ?? '[]', true);
if (is_array($msgs)) {
foreach ($msgs as $m) {
$role = $m['role'] ?? '';
$text = trim((string)($m['content'] ?? ''));
if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
}
}
// Capped at the deepest profile's window rather than that of the profile in hand. A chat
// saved under one profile can be reopened under another, and the reopened turn is trimmed
// again on the way back out by ask — so storing a little more than any single profile will
// send costs nothing and keeps the transcript readable.
$cap = vv_ai_profiles_max_turns() * 2;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
// Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name from
// a page's own view state, it is stored and later replayed into a prompt, and anything
// richer than a file name is an instruction-injection surface for no benefit.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope);
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
: 'FAILED: ' . $r['error']));
echo json_encode($r);
exit;
}
if ($action === 'chat_delete') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$ok = vv_ai_chat_delete(trim($_POST['id'] ?? ''));
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']);
exit;
}
// ── bugs / bug_close ──────────────────────────────────────────────────────────
// Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is
// a POST, like every other mutation in this plugin.
if ($action === 'bugs') {
echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]);
exit;
}
if ($action === 'bug_close') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1');
echo json_encode(['ok' => $ok]);
exit;
}
// The report, rendered server-side. Read-only by design: what the operator reviews is byte for
// byte what gets sent, so approving one text and transmitting another is not possible. It is also
// the only renderer — the page used to build its own markdown, which is two formats to keep in
// step and one of them always losing.
if ($action === 'bug_report') {
$id = trim($_GET['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }
$t = vv_ai_bug_targets();
$title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? '');
echo json_encode([
'ok' => true,
'title' => $title,
'markdown' => vv_ai_bug_report($bug),
'targets' => $t,
// Built here because the repo name lives here. Length is the caller's problem to notice:
// GitHub truncates a very long query rather than refusing it, which would silently send a
// half report — so the page checks and falls back to the copy box.
'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title='
. rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)),
]);
exit;
}
// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure: the
// two destinations are different people, and a silent substitution is how a report meant for a
// private backlog ends up public.
if ($action === 'bug_send_local') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$id = trim($_POST['id'] ?? '');
$bug = null;
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }
// Re-rendered from the store rather than taken from the request. The browser showed this text
// read-only; accepting a body from the page would make that guarantee decorative.
$r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''),
vv_ai_bug_report($bug));
vv_ai_log(sprintf('bug_send_local id=%s %s', $id,
$r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?')));
echo json_encode($r);
exit;
}
// ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled
// in here rather than at the top of the file: it is the largest include in the plugin and poll
// runs once a second per open tab, so it is loaded by the two actions that need it and by nothing
// else.
//
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being
// true when it goes off, and answering them — including saying "this was never a problem" — is
// exactly what an operator turning the feature off is likely to want to do first. The gate states
// are reported instead, so the card can say what is running rather than the endpoint pretending
// the store is empty.
if ($action === 'findings' || $action === 'finding_action') {
require_once dirname(__DIR__) . '/include/ai_repair.php';
if ($action === 'findings') {
// Closed findings are the history — what was dismissed, what a fix actually fixed — and
// they are asked for explicitly rather than shipped with every poll of the open list.
$rows = [];
$open = 0; $needs = 0;
foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
$state = (string)($f['state'] ?? 'open');
if ($state === 'open') $open++;
elseif ($state === 'needs_operator') $needs++;
// The three things the page must not decide for itself: which actions this row
// offers, and what its state and kind mean in words.
$f['actions'] = vv_ai_finding_actions($f);
$f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
$f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
$rows[] = $f;
}
echo json_encode(['ok' => true,
'repair' => ['enabled' => vv_ai_repair_enabled(),
'autofix' => vv_ai_repair_autofix_enabled(),
'last' => vv_ai_sweep_last()],
'findings' => $rows,
'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]);
exit;
}
// POST, because fix writes conf through the guarded path and every other answer writes state.
// Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this
// endpoint's — a tab left open overnight is holding buttons the store has moved past.
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$fid = trim($_POST['id'] ?? '');
$act = trim($_POST['act'] ?? '');
$r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? ''));
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
echo json_encode($r);
exit;
}
// ── incident_add ──────────────────────────────────────────────────────────────
// Appends one operator-written "this was the fix" note against a scope. POST only, and the
// scope is whitelisted the same way ask's is — it is written to a file that later rides in a
// prompt, so it gets the same treatment as anything else that reaches the model.
if ($action === 'incident_add') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
echo json_encode(vv_ai_incident_add(
trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? '')));
exit;
}
// ── ask ───────────────────────────────────────────────────────────────────────
if ($action === 'ask') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
$cfg = vv_ai_config();
if ($cfg['model'] === '') {
echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit;
}
$question = trim($_POST['question'] ?? '');
if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; }
if (mb_strlen($question) > VV_AI_MAX_QUESTION) {
echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit;
}
$profile = trim($_POST['profile'] ?? 'varaverk');
if (!vv_ai_profile_ok($profile)) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
}
$maxTurns = vv_ai_profile_turns($profile);
// Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
// The scheduler page sends it so a question can say "this setting" and mean something; the
// AI tab sends nothing and the worker simply omits the location line.
//
// Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything
// richer than a file name is an instruction-injection surface for no benefit — a scope is
// only ever a name from this page's own view state.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
// The retrieval filter only means anything to the profile that retrieves.
$kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($_POST['kind'] ?? '') : '';
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit;
}
// Validate per message rather than trusting the blob: a crafted history could otherwise
// inject a system role, or push the context past the offload ceiling.
$clean = [];
$hist = json_decode($_POST['history'] ?? '[]', true);
if (is_array($hist)) {
foreach ($hist as $m) {
$role = $m['role'] ?? '';
$text = trim((string)($m['content'] ?? ''));
if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
}
}
if (count($clean) > $maxTurns * 2) {
$clean = array_slice($clean, -($maxTurns * 2));
}
$dir = vv_ai_job_dir();
foreach (glob($dir . '/*.json') ?: [] as $old) {
if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old);
}
$token = bin2hex(random_bytes(16));
$jobFile = vv_ai_job_path($token);
$worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php';
if (!file_exists($worker)) {
echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit;
}
// Not suppressed: if the job file cannot be written the worker has nowhere to report and
// the page polls a token that will never resolve — which looks exactly like a hang.
if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) {
vv_ai_log('ask FAILED — cannot write ' . $jobFile);
echo json_encode(['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR]);
exit;
}
// setsid, not just nohup. nohup detaches from the terminal but leaves the child in the
// caller's process group — php-fpm's. That is the arrangement that took the WebGUI down on
// 2026-08-07 when a Stop signalled a group it did not own. Stop below signals one verified
// pid and never a group, so this is belt and braces, but it also means a php-fpm restart no
// longer takes a running generation with it.
$cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' '
. escapeshellarg($jobFile) . ' '
. escapeshellarg($question) . ' '
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
. escapeshellarg($profile) . ' '
. escapeshellarg($scope) . ' '
// Asked for per turn. Only meaningful on a profile holding web_search — the worker
// checks that, so a crafted web=1 against any other profile changes nothing.
. escapeshellarg(($_POST['web'] ?? '') === '1' ? '1' : '0')
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
// Redacted before it is logged, for the same reason the stored transcript is: asking the
// assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-',
mb_substr(vv_ai_redact($question), 0, 80)));
echo json_encode(['ok' => true, 'token' => $token]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
if ($httpStatus !== 200) http_response_code($httpStatus);
echo json_encode($body);