Add the AI tab — grounded chat over the documentation index

Token and poll rather than SSE, so the api layer keeps one response
convention and reuses the pattern manual_sync already proved. History is
capped at three turns because the model is only fully offloaded at 16384
context and unbounded history would cross that silently. The tab exists
only while AI_ENABLED is true, rejected server-side and not merely hidden.
This commit is contained in:
Gmer4Lfe
2026-08-02 17:32:17 -04:00
parent a537ae4217
commit 264ba57cbb
6 changed files with 1053 additions and 5 deletions
+187
View File
@@ -0,0 +1,187 @@
<?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
// ask is refused when AI is disabled.
// AI_ENABLED gates the whole subsystem; the tab is hidden when it is false, but hiding
// a link is not access control and the endpoint is reachable directly.
//
// 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
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
// 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
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';
const VV_AI_MAX_TURNS = 3; // user+assistant pairs retained; see OPERATIONAL MODEL
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'));
// ── stats ─────────────────────────────────────────────────────────────────────
if ($action === 'stats') {
echo json_encode(['ok' => true, 'stats' => vv_ai_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;
}
// ── 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;
}
// ── ask ───────────────────────────────────────────────────────────────────────
if ($action === 'ask') {
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
if (!vv_ai_enabled()) {
echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']); 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;
}
$kind = 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) > VV_AI_MAX_TURNS * 2) {
$clean = array_slice($clean, -(VV_AI_MAX_TURNS * 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;
}
@file_put_contents($jobFile, json_encode(['status' => 'pending']));
$cmd = 'nohup php ' . escapeshellarg($worker) . ' '
. escapeshellarg($jobFile) . ' '
. escapeshellarg($question) . ' '
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0')
. ' >/dev/null 2>&1 </dev/null &';
exec($cmd);
echo json_encode(['ok' => true, 'token' => $token]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);