274 lines
14 KiB
PHP
274 lines
14 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.
|
|
@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.
|
|
const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4];
|
|
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;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
$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;
|
|
|
|
// The retrieval filter only means anything to the profile that retrieves.
|
|
$kind = $profile === 'varaverk' ? 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)
|
|
. ' >/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']);
|