job state // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=clear token= discard a finished job // // RESPONSE // stats {"ok":true,"stats":{…}} // ask {"ok":true,"token":""} // 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')); // 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'] ?? '?')); } // ── 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; } // 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') . ' >/dev/null 2>&1 true, 'token' => $token]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);