Curated state copied to every node is state that can disagree, so the index, the model and the shared memory stay on the owner and each node reaches them over the SSH trust onboarding already builds. Chats stay on the node that had them; memory and bug reports stay the owner's to write.
543 lines
30 KiB
PHP
543 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')
|
|
. ' >/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'];
|
|
}
|