Files
Gmer4Lfe 0621f66889 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.
2026-08-20 19:53:28 -04:00

154 lines
8.7 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// 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
// that starts it. POST action=ask spawns Tools/ai_chat_worker.php detached and returns a
// token immediately; the page polls action=poll until the job file reaches a terminal state.
// This is the same shape api/manual_sync.php uses for long rsync runs, chosen over SSE so
// the whole api layer keeps one response convention and one error path.
//
// Conversation history is capped here, not in the worker. The model is fully offloaded only
// at 16384 context, and retrieved chunks plus reasoning already consume several thousand
// tokens — unbounded history would silently cross that ceiling mid-conversation and cost
// roughly 4x throughput. One place owns that policy.
//
// DESIGN PRINCIPLES
// Reads are GET, work is POST.
// stats and poll change nothing and are safe to repeat. ask spawns a process, so it is
// POST and therefore covered by Unraid's CSRF prepend, which inspects no GET at all.
//
// The banner is a separate action from the chat.
// It is polled on a slow cycle and must keep rendering while a turn is in flight, so it
// shares no state with the job.
//
// Tokens are minted here and never accepted from elsewhere.
// random_bytes, hex, fixed length. A job's answer is readable by anyone who can guess
// its token, so the token is not guessable.
//
// OPERATIONAL SAFEGUARDS
// 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.
//
// 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.
//
// 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.
//
// 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
// GET ?action=poll&token=<hex32> job state
// GET ?action=memory_get the operator memory file and its budget
// GET ?action=chats stored conversations, newest first, metadata only
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
// GET ?action=findings [all=1] repair findings, open only unless all=1
// POST action=finding_action id=<hex12> act=fix|move|ack|dismiss|reopen|cancel [note=…]
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
// POST action=memory_set memory=… replace the memory file
// POST action=clear token=<hex32> discard a finished job
// POST action=chat_save [id=<hex32>] profile=… messages=<JSON>
// POST action=chat_delete id=<hex32>
//
// RESPONSE
// stats {"ok":true,"stats":{…}}
// ask {"ok":true,"token":"<hex32>"}
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
// clear {"ok":true}
// chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
// findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}}
// finding_action {"ok":true,"action":"fix"}
// chat_save {"ok":true,"id":"<hex32>","title":…}
// {"ok":false,"error":…}
//
// DEPENDS ON
// 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
// action log below, and those two look identical from outside — which is what made a POST that
// the browser demonstrably sent leave no trace anywhere on the server.
// Polls are excluded here for the same reason they are excluded from the action log below: one
// line per second per open tab buries every line worth reading.
if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) {
@file_put_contents('/var/log/varaverk/ai.log',
date('Y-m-d H:i:s') . ' ENTER ' . ($_SERVER['REQUEST_METHOD'] ?? '?')
. ' ' . ($_SERVER['REQUEST_URI'] ?? '?')
. ' ct=' . substr($_SERVER['CONTENT_TYPE'] ?? '-', 0, 40)
. ' 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_actions.php';
require_once dirname(__DIR__) . '/include/ai_rpc.php';
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats'));
// 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'] ?? '?'));
}
// 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;
}
// 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;
}
$body = $route === VV_AI_ROUTE_REMOTE
? vv_ai_rpc($action, $params, $isPost, $httpStatus)
: vv_ai_dispatch($action, $params, $isPost, $httpStatus);
if ($httpStatus !== 200) http_response_code($httpStatus);
echo json_encode($body);