Files
Varaverk/Plugin/unraid/api/ai.php
T
Gmer4Lfe be306cf86c Let one gate decide every AI surface, so switching AI off actually removes all of it
The Tools card adopted the AI scripts on the host check alone, and api/ai.php only
tested AI_ENABLED in front of ask, so a disabled subsystem still had rows to run and
an endpoint that answered.
2026-08-07 09:49:31 -04:00

322 lines
17 KiB
PHP

<?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.
//
// 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
// 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.
//
// Every token is validated as hex before it composes a path.
// vv_ai_job_path() returns null for anything else, and each caller checks. That pattern
// is what confines reads and deletes to the job directory.
//
// History is validated per message, not trusted as a blob.
// Role must be user or assistant, content must be a non-empty string, and each is
// truncated. A crafted history could otherwise inject a system role or push the context
// past the offload ceiling.
//
// 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.
//
// REQUEST
// GET ?action=stats banner payload
// GET ?action=poll&token=<hex32> job state
// GET ?action=memory_get the operator memory file and its budget
// 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
//
// RESPONSE
// stats {"ok":true,"stats":{…}}
// ask {"ok":true,"token":"<hex32>"}
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
// clear {"ok":true}
// {"ok":false,"error":…}
//
// DEPENDS ON
// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
// Tools/ai_chat_worker.php the detached worker
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// 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.php';
// History depth is per profile, and decided here rather than by the page. Varaverk Assistant
// spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other
// two retrieve nothing and can carry a real conversation. Reasoning is not stored in history,
// so it does not compound.
// troubleshoot carries a whole log tail into context, so its history is the shallowest of the
// four — the evidence for "why did this fail" is the log in front of it, not the conversation.
const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4, 'troubleshoot' => 2];
const VV_AI_MAX_TURNS = 3; // fallback when a profile is not recognised
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
$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);
}
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_is_ai_host()) {
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.
if (!vv_ai_enabled()) {
echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']);
exit;
}
// ── stats ─────────────────────────────────────────────────────────────────────
if ($action === 'stats') {
echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]);
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;
}
// ── 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;
}
// ── 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;
}
// ── 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;
}
// ── 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 (!isset(VV_AI_PROFILES[$profile])) {
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
}
$maxTurns = VV_AI_PROFILES[$profile] ?? VV_AI_MAX_TURNS;
// 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;
}
$cmd = '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)
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-', mb_substr($question, 0, 80)));
echo json_encode(['ok' => true, 'token' => $token]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);