Every turn runs on the AI owner now, so the ledger's per-host column had collapsed to a single name and the fleet breakdown said nothing. The asking node travels with the request and is validated before it is written, which also retires ai_token_sync.sh — a partner ledger is empty by construction.
547 lines
30 KiB
PHP
547 lines
30 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Every AI action, in one dispatcher, independent of how the request arrived. api/ai.php calls
|
|
// it for browser requests; Tools/ai_rpc.php calls it for mesh requests forwarded from another
|
|
// node over SSH. Both get identical behaviour because there is only one implementation.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Transport-free. Nothing here reads a superglobal, sets a header, echoes, or exits. Handlers
|
|
// take a params array and return the response body as an array. That is what makes the
|
|
// same code serveable over HTTP and over SSH — the handlers cannot tell the difference,
|
|
// so the two paths cannot drift.
|
|
//
|
|
// The dispatcher owns behaviour; the caller owns access.
|
|
// Authentication, CSRF and the node/action gates live in the caller. This file assumes
|
|
// the request is already allowed. Two callers with different trust models share one set
|
|
// of handlers precisely because the handlers do not re-litigate trust.
|
|
//
|
|
// Extracted, not rewritten.
|
|
// This was the body of api/ai.php. The transformation is mechanical — `echo json_encode(X);
|
|
// exit` became `return X`, a 405 became $httpStatus plus a return, and superglobals became
|
|
// $p. Every validation, every whitelist and every comment is the original. A refactor of a
|
|
// file this security-sensitive earns nothing by also being a redesign.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// 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.
|
|
//
|
|
// 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. chat_save applies the same validation, because a stored chat
|
|
// is replayed into a later prompt.
|
|
//
|
|
// Scope is whitelisted, not escaped.
|
|
// It reaches the model as text, so anything richer than a file name is an
|
|
// instruction-injection surface for no benefit.
|
|
//
|
|
// stop signals one verified pid, never a process group.
|
|
// The cmdline must name both the worker and this job's own token before anything is
|
|
// signalled, because pid reuse is real. Group signalling took the WebGUI down on
|
|
// 2026-08-07.
|
|
//
|
|
// EXPORTS
|
|
// vv_ai_dispatch() action → response body
|
|
// vv_ai_log() the shared request/action trace at /var/log/varaverk/ai.log
|
|
//
|
|
// DEPENDS ON
|
|
// include/ai.php config, stats, memory, chats, bugs, job files
|
|
// include/ai_memory_learn.php loaded per action — learned-memory proposals
|
|
// include/ai_repair.php loaded per action — the largest include in the plugin
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
require_once __DIR__ . '/ai.php';
|
|
|
|
// History depth is per profile and still decided server-side rather than by the page — the page
|
|
// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
|
|
// Reasoning is not stored in history, so it does not compound.
|
|
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
|
|
|
|
// 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. Callers exclude 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);
|
|
}
|
|
|
|
// Run one action. $p carries the request parameters regardless of how they arrived; $isPost is
|
|
// still passed separately because the POST-only checks are a CSRF guarantee, not a parameter.
|
|
//
|
|
// $httpStatus is by reference and named for what it is — `$status` is taken, by stop's own local
|
|
// for a job's state, and shadowing that would be a silent bug in the one handler that kills a
|
|
// process.
|
|
function vv_ai_dispatch(string $action, array $p, bool $isPost, int &$httpStatus = 200): array {
|
|
|
|
$postOnly = function () use (&$httpStatus): array {
|
|
$httpStatus = 405;
|
|
return ['ok' => false, 'error' => 'POST only'];
|
|
};
|
|
|
|
// ── stats ─────────────────────────────────────────────────────────────────────
|
|
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
|
|
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
|
|
// every open tab and used to pay a full collection each time — around a second, most of it
|
|
// spent waiting on Ollama and nvidia-smi — for numbers that only change when the writer runs.
|
|
//
|
|
// live=1 bypasses it, for the case where something was just changed and the point is to see
|
|
// the result. A missing cache always falls back to collecting, so the cache can never be the
|
|
// reason the banner fails to render.
|
|
if ($action === 'stats') {
|
|
return ['ok' => true, 'stats' => vv_ai_stats_cached(isset($p['live']))];
|
|
}
|
|
|
|
// ── 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') {
|
|
return ['ok' => true, 'tokens' => vv_ai_token_stats()];
|
|
}
|
|
|
|
// ── poll ──────────────────────────────────────────────────────────────────────
|
|
if ($action === 'poll') {
|
|
$token = trim($p['token'] ?? '');
|
|
if (vv_ai_job_path($token) === null) {
|
|
return ['ok' => false, 'error' => 'Invalid token'];
|
|
}
|
|
$job = vv_ai_job_read($token);
|
|
if ($job === null) {
|
|
// The worker writes its first state after this request may already have arrived.
|
|
return ['ok' => true, 'job' => ['status' => 'pending']];
|
|
}
|
|
return ['ok' => true, 'job' => $job];
|
|
}
|
|
|
|
// ── memory ────────────────────────────────────────────────────────────────────
|
|
if ($action === 'memory_get') {
|
|
$m = vv_ai_memory_read();
|
|
return ['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'],
|
|
'max' => vv_ai_memory_max(), 'exists' => $m['exists'],
|
|
'path' => vv_ai_memory_path()];
|
|
}
|
|
|
|
if ($action === 'memory_set') {
|
|
if (!$isPost) return $postOnly();
|
|
$r = vv_ai_memory_write((string)($p['memory'] ?? ''));
|
|
vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error']));
|
|
return $r + ['max' => vv_ai_memory_max()];
|
|
}
|
|
|
|
// ── learned-memory proposals ──────────────────────────────────────────────────
|
|
// The store the assistant files candidates into. Accepting is the only path by which
|
|
// model-written text reaches a prompt, and it is a POST so the CSRF prepend covers it.
|
|
if ($action === 'mem_proposals') {
|
|
require_once __DIR__ . '/ai_memory_learn.php';
|
|
$m = vv_ai_memory_read('learned');
|
|
return [
|
|
'ok' => true,
|
|
'enabled' => vv_ai_mem_learn_enabled(),
|
|
'auto' => vv_ai_mem_learn_auto(),
|
|
// The list states the gate as well as the rows: an empty list means "nothing proposed"
|
|
// when learning is on and "nothing is looking" when it is off, and those are different.
|
|
'open' => vv_ai_mem_list('open'),
|
|
'recent' => array_slice(vv_ai_mem_list(), 0, 25),
|
|
'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()],
|
|
];
|
|
}
|
|
|
|
if ($action === 'mem_proposal_action') {
|
|
if (!$isPost) return $postOnly();
|
|
require_once __DIR__ . '/ai_memory_learn.php';
|
|
$id = trim($p['id'] ?? '');
|
|
$act = trim($p['act'] ?? '');
|
|
$r = vv_ai_mem_action($id, $act);
|
|
vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act,
|
|
$r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?'))));
|
|
return $r;
|
|
}
|
|
|
|
// ── stop ──────────────────────────────────────────────────────────────────────
|
|
// Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for
|
|
// this exact job — never a process group. Signalling a group is what took the WebGUI down on
|
|
// 2026-08-07, and no group kill is needed here: the worker is a single php process whose only
|
|
// child-like thing is an HTTP connection to Ollama, which dies with it.
|
|
//
|
|
// Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the
|
|
// operator has seen enough, not because they want it discarded.
|
|
if ($action === 'stop') {
|
|
if (!$isPost) return $postOnly();
|
|
|
|
$token = trim($p['token'] ?? '');
|
|
if (vv_ai_job_path($token) === null) {
|
|
return ['ok' => false, 'error' => 'Invalid token'];
|
|
}
|
|
|
|
$job = vv_ai_job_read($token);
|
|
if ($job === null) return ['ok' => false, 'error' => 'No such job'];
|
|
|
|
$status = (string)($job['status'] ?? '');
|
|
if ($status === 'done' || $status === 'error' || $status === 'stopped') {
|
|
return ['ok' => true, 'already' => true, 'status' => $status];
|
|
}
|
|
|
|
$pid = (int)($job['pid'] ?? 0);
|
|
// Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal.
|
|
$killed = false;
|
|
if ($pid >= 2) {
|
|
// Pid reuse is the reason for this: the recorded worker may have exited seconds ago
|
|
// and the number been handed to something else entirely. The cmdline must name both
|
|
// this worker and this job's own file before anything is signalled.
|
|
$cmdline = @file_get_contents("/proc/$pid/cmdline");
|
|
$cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline);
|
|
if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) {
|
|
$killed = @posix_kill($pid, SIGTERM);
|
|
// No escalation ladder. The worker holds no lock and writes the job file
|
|
// atomically, so there is no cleanup that a delay would protect — and a SIGKILL
|
|
// race could land between the temp write and the rename.
|
|
}
|
|
}
|
|
|
|
// The job file is rewritten either way. If the pid could not be verified the worker is
|
|
// already gone, and the page still needs a terminal state instead of polling to its
|
|
// ceiling.
|
|
$job['status'] = 'stopped';
|
|
$job['stopped'] = true;
|
|
$job['answer'] = trim((string)($job['partial'] ?? $job['answer'] ?? ''));
|
|
unset($job['partial']);
|
|
@file_put_contents(vv_ai_job_path($token), json_encode($job));
|
|
|
|
vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars',
|
|
substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer'])));
|
|
|
|
return ['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])];
|
|
}
|
|
|
|
// ── clear ─────────────────────────────────────────────────────────────────────
|
|
if ($action === 'clear') {
|
|
if (!$isPost) return $postOnly();
|
|
$path = vv_ai_job_path(trim($p['token'] ?? ''));
|
|
if ($path === null) return ['ok' => false, 'error' => 'Invalid token'];
|
|
if (file_exists($path)) @unlink($path);
|
|
return ['ok' => true];
|
|
}
|
|
|
|
// ── chats ─────────────────────────────────────────────────────────────────────
|
|
// Stored conversations. Listing and reading are GET because they change nothing; saving and
|
|
// deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here.
|
|
//
|
|
// Messages are validated per message on the way in, exactly as ask validates history and for
|
|
// the same reason: a stored chat is replayed into a later prompt when the operator reopens it,
|
|
// so a crafted role in the store would be an injection that survives a reload.
|
|
if ($action === 'chats') {
|
|
return ['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()];
|
|
}
|
|
|
|
if ($action === 'chat_get') {
|
|
$chat = vv_ai_chat_read(trim($p['id'] ?? ''));
|
|
if ($chat === null) return ['ok' => false, 'error' => 'No such chat'];
|
|
return ['ok' => true, 'chat' => $chat];
|
|
}
|
|
|
|
if ($action === 'chat_save') {
|
|
if (!$isPost) return $postOnly();
|
|
|
|
$profile = trim($p['profile'] ?? 'chat');
|
|
if (!vv_ai_profile_ok($profile)) {
|
|
return ['ok' => false, 'error' => 'Unknown profile: ' . $profile];
|
|
}
|
|
|
|
$clean = [];
|
|
$msgs = json_decode($p['messages'] ?? '[]', true);
|
|
if (is_array($msgs)) {
|
|
foreach ($msgs 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)];
|
|
}
|
|
}
|
|
// Capped at the deepest profile's window rather than that of the profile in hand. A chat
|
|
// saved under one profile can be reopened under another, and the reopened turn is trimmed
|
|
// again on the way back out by ask — so storing a little more than any single profile will
|
|
// send costs nothing and keeps the transcript readable.
|
|
$cap = vv_ai_profiles_max_turns() * 2;
|
|
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
|
|
|
|
// Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name
|
|
// from a page's own view state, it is stored and later replayed into a prompt, and
|
|
// anything richer than a file name is an instruction-injection surface for no benefit.
|
|
$scope = trim($p['scope'] ?? '');
|
|
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
|
|
|
|
$r = vv_ai_chat_save(trim($p['id'] ?? ''), $profile, $clean, $scope);
|
|
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
|
|
: 'FAILED: ' . $r['error']));
|
|
return $r;
|
|
}
|
|
|
|
if ($action === 'chat_delete') {
|
|
if (!$isPost) return $postOnly();
|
|
$ok = vv_ai_chat_delete(trim($p['id'] ?? ''));
|
|
return ['ok' => $ok, 'error' => $ok ? null : 'No such chat'];
|
|
}
|
|
|
|
// ── 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') {
|
|
return ['ok' => true, 'bugs' => vv_ai_bugs_list(($p['all'] ?? '') !== '1')];
|
|
}
|
|
if ($action === 'bug_close') {
|
|
if (!$isPost) return $postOnly();
|
|
$ok = vv_ai_bug_set_open(trim($p['id'] ?? ''), ($p['open'] ?? '0') === '1');
|
|
return ['ok' => $ok];
|
|
}
|
|
|
|
// The report, rendered server-side. Read-only by design: what the operator reviews is byte for
|
|
// byte what gets sent, so approving one text and transmitting another is not possible. It is
|
|
// also the only renderer — the page used to build its own markdown, which is two formats to
|
|
// keep in step and one of them always losing.
|
|
if ($action === 'bug_report') {
|
|
$id = trim($p['id'] ?? '');
|
|
$bug = null;
|
|
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
|
|
if (!$bug) return ['ok' => false, 'error' => 'no such report'];
|
|
|
|
$t = vv_ai_bug_targets();
|
|
$title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? '');
|
|
return [
|
|
'ok' => true,
|
|
'title' => $title,
|
|
'markdown' => vv_ai_bug_report($bug),
|
|
'targets' => $t,
|
|
// Built here because the repo name lives here. Length is the caller's problem to
|
|
// notice: GitHub truncates a very long query rather than refusing it, which would
|
|
// silently send a half report — so the page checks and falls back to the copy box.
|
|
'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title='
|
|
. rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)),
|
|
];
|
|
}
|
|
|
|
// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure:
|
|
// the two destinations are different people, and a silent substitution is how a report meant
|
|
// for a private backlog ends up public.
|
|
if ($action === 'bug_send_local') {
|
|
if (!$isPost) return $postOnly();
|
|
$id = trim($p['id'] ?? '');
|
|
$bug = null;
|
|
foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
|
|
if (!$bug) return ['ok' => false, 'error' => 'no such report'];
|
|
|
|
// Re-rendered from the store rather than taken from the request. The browser showed this
|
|
// text read-only; accepting a body from the page would make that guarantee decorative.
|
|
$r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''),
|
|
vv_ai_bug_report($bug));
|
|
vv_ai_log(sprintf('bug_send_local id=%s %s', $id,
|
|
$r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?')));
|
|
return $r;
|
|
}
|
|
|
|
// ── findings / finding_action ─────────────────────────────────────────────────
|
|
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is
|
|
// pulled in here rather than at the top of the file: it is the largest include in the plugin
|
|
// and poll runs once a second per open tab, so it is loaded by the two actions that need it
|
|
// and by nothing else.
|
|
//
|
|
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop
|
|
// being true when it goes off, and answering them — including saying "this was never a
|
|
// problem" — is exactly what an operator turning the feature off is likely to want to do
|
|
// first. The gate states are reported instead, so the card can say what is running rather
|
|
// than the endpoint pretending the store is empty.
|
|
if ($action === 'findings' || $action === 'finding_action') {
|
|
require_once __DIR__ . '/ai_repair.php';
|
|
|
|
if ($action === 'findings') {
|
|
// Closed findings are the history — what was dismissed, what a fix actually fixed —
|
|
// and they are asked for explicitly rather than shipped with every poll of the list.
|
|
$rows = [];
|
|
$open = 0; $needs = 0;
|
|
foreach (vv_ai_findings_list(($p['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
|
|
$state = (string)($f['state'] ?? 'open');
|
|
if ($state === 'open') $open++;
|
|
elseif ($state === 'needs_operator') $needs++;
|
|
// The three things the page must not decide for itself: which actions this row
|
|
// offers, and what its state and kind mean in words.
|
|
$f['actions'] = vv_ai_finding_actions($f);
|
|
$f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
|
|
$f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
|
|
$rows[] = $f;
|
|
}
|
|
return ['ok' => true,
|
|
'repair' => ['enabled' => vv_ai_repair_enabled(),
|
|
'autofix' => vv_ai_repair_autofix_enabled(),
|
|
'last' => vv_ai_sweep_last()],
|
|
'findings' => $rows,
|
|
'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]];
|
|
}
|
|
|
|
// POST, because fix writes conf through the guarded path and every other answer writes
|
|
// state. Which actions are legal for a given row is vv_ai_finding_apply_action()'s call,
|
|
// not this endpoint's — a tab left open overnight is holding buttons the store has moved
|
|
// past.
|
|
if (!$isPost) return $postOnly();
|
|
|
|
$fid = trim($p['id'] ?? '');
|
|
$act = trim($p['act'] ?? '');
|
|
$r = vv_ai_finding_apply_action($fid, $act, trim($p['note'] ?? ''));
|
|
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
|
|
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
|
|
return $r;
|
|
}
|
|
|
|
// ── finding_write ─────────────────────────────────────────────────────────────
|
|
// A sweep on another node filing what it found. Not reachable from a browser — vv_ai_route()
|
|
// never returns LOCAL for it off the owner and the page has no caller — it exists so that
|
|
// "sweep local, store central" needs no second store and no reconciliation.
|
|
//
|
|
// The host is taken from the transport's own view of who connected, never from the payload.
|
|
// The record decides which machine a fault is about and is what the finding id hashes on, so
|
|
// letting the body name it would let one node file findings as another.
|
|
if ($action === 'finding_write') {
|
|
if (!$isPost) return $postOnly();
|
|
require_once __DIR__ . '/ai_repair.php';
|
|
|
|
$f = json_decode((string)($p['finding'] ?? ''), true);
|
|
if (!is_array($f)) return ['ok' => false, 'error' => 'finding_write: unreadable finding'];
|
|
|
|
$node = trim((string)($p['_vv_node'] ?? ''));
|
|
if (!preg_match('/^host\d+$/', $node)) {
|
|
return ['ok' => false, 'error' => 'finding_write: caller did not identify a node'];
|
|
}
|
|
$f['host'] = $node;
|
|
|
|
$r = vv_ai_finding_write_local($f);
|
|
vv_ai_log(sprintf('finding_write from=%s kind=%s %s', $node, (string)($f['kind'] ?? '?'),
|
|
($r['ok'] ?? false) ? 'ok id=' . ($r['id'] ?? '?') : 'FAILED: ' . ($r['error'] ?? '?')));
|
|
return $r;
|
|
}
|
|
|
|
// ── 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) return $postOnly();
|
|
return vv_ai_incident_add(
|
|
trim($p['scope'] ?? ''), trim($p['symptom'] ?? ''), trim($p['fix'] ?? ''));
|
|
}
|
|
|
|
// ── ask ───────────────────────────────────────────────────────────────────────
|
|
if ($action === 'ask') {
|
|
if (!$isPost) return $postOnly();
|
|
|
|
$cfg = vv_ai_config();
|
|
if ($cfg['model'] === '') {
|
|
return ['ok' => false, 'error' => 'No generation model configured'];
|
|
}
|
|
|
|
$question = trim($p['question'] ?? '');
|
|
if ($question === '') return ['ok' => false, 'error' => 'question is required'];
|
|
if (mb_strlen($question) > VV_AI_MAX_QUESTION) {
|
|
return ['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters'];
|
|
}
|
|
|
|
$profile = trim($p['profile'] ?? 'varaverk');
|
|
if (!vv_ai_profile_ok($profile)) {
|
|
return ['ok' => false, 'error' => 'Unknown profile: ' . $profile];
|
|
}
|
|
$maxTurns = vv_ai_profile_turns($profile);
|
|
|
|
// 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($p['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($p['kind'] ?? '') : '';
|
|
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
|
|
return ['ok' => false, 'error' => 'Unknown kind: ' . $kind];
|
|
}
|
|
|
|
// 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($p['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)) {
|
|
return ['ok' => false, 'error' => 'ai_chat_worker.php not found'];
|
|
}
|
|
|
|
// 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);
|
|
return ['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR];
|
|
}
|
|
|
|
// setsid, not just nohup. nohup detaches from the terminal but leaves the child in the
|
|
// caller's process group — php-fpm's. That is the arrangement that took the WebGUI down
|
|
// on 2026-08-07 when a Stop signalled a group it did not own. Stop above signals one
|
|
// verified pid and never a group, so this is belt and braces, but it also means a php-fpm
|
|
// restart no longer takes a running generation with it.
|
|
$cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' '
|
|
. escapeshellarg($jobFile) . ' '
|
|
. escapeshellarg($question) . ' '
|
|
. escapeshellarg(json_encode($clean)) . ' '
|
|
. escapeshellarg($kind) . ' '
|
|
. escapeshellarg(($p['think'] ?? '1') === '1' ? '1' : '0') . ' '
|
|
. escapeshellarg($profile) . ' '
|
|
. escapeshellarg($scope) . ' '
|
|
// Asked for per turn. Only meaningful on a profile holding web_search — the worker
|
|
// checks that, so a crafted web=1 against any other profile changes nothing.
|
|
. escapeshellarg(($p['web'] ?? '') === '1' ? '1' : '0') . ' '
|
|
// Who asked. Set by the RPC layer for a forwarded turn and absent for a local one,
|
|
// where the worker's own default is already correct. This is what keeps the token
|
|
// ledger's per-host column meaningful once every turn generates on the owner.
|
|
. escapeshellarg((string)($p['_vv_node'] ?? ''))
|
|
. ' >/dev/null 2>&1 </dev/null &';
|
|
$out = []; $rc = 0;
|
|
exec($cmd, $out, $rc);
|
|
// Redacted before it is logged, for the same reason the stored transcript is: asking the
|
|
// assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
|
|
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
|
|
substr($token, 0, 12), $rc, $profile, $kind ?: '-',
|
|
mb_substr(vv_ai_redact($question), 0, 80)));
|
|
|
|
return ['ok' => true, 'token' => $token];
|
|
}
|
|
|
|
return ['ok' => false, 'error' => 'Unknown action'];
|
|
}
|