Search is the one capability that fits the profile holding none: every other capability either reads this installation or changes it, and chat's whole contract is that it can do neither. The assistant deliberately does not get it — its contract is that answers come from this install's own documents, and a web result there is an answer that looks sourced and is not. A chat question about this machine hands off to the assistant before the search would run, so it never reaches the internet even with the box ticked. Off by default, and not because it is dangerous: searching sends the operator's question outside the house, which is theirs to decide. Asked for per turn as well as enabled in conf. Provider-agnostic, as asked — searxng, brave, tavily. Only whichever is configured here can be verified; all three read every field by name, so a shape that changes underneath yields no results rather than wrong ones. The explain fixtures asserted chat holds no capabilities at all, which is exactly the guarantee worth keeping. caps=only: now states the set rather than its emptiness, so the check still fails the day something else is granted there.
461 lines
25 KiB
PHP
461 lines
25 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. chat_save applies the same validation, because a stored
|
|
// conversation is replayed into a later prompt when it is reopened — an unchecked role
|
|
// written there is an injection that survives a reload rather than one turn.
|
|
//
|
|
// 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
|
|
// GET ?action=chats stored conversations, newest first, metadata only
|
|
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
|
|
// GET ?action=findings [all=1] repair findings, open only unless all=1
|
|
// POST action=finding_action id=<hex12> act=fix|ack|dismiss|reopen|cancel [note=…]
|
|
// 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
|
|
// POST action=chat_save [id=<hex32>] profile=… messages=<JSON>
|
|
// POST action=chat_delete id=<hex32>
|
|
//
|
|
// RESPONSE
|
|
// stats {"ok":true,"stats":{…}}
|
|
// ask {"ok":true,"token":"<hex32>"}
|
|
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
|
|
// clear {"ok":true}
|
|
// chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
|
|
// findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}}
|
|
// finding_action {"ok":true,"action":"fix"}
|
|
// chat_save {"ok":true,"id":"<hex32>","title":…}
|
|
// {"ok":false,"error":…}
|
|
//
|
|
// DEPENDS ON
|
|
// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
|
|
// include/ai_repair.php the findings store — loaded only by the two actions that read it
|
|
// 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.
|
|
// Polls are excluded here for the same reason they are excluded from the action log below: one
|
|
// line per second per open tab buries every line worth reading.
|
|
if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) {
|
|
@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 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
|
|
|
|
$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;
|
|
}
|
|
|
|
// Master switch, on the same footing as the host gate rather than only in front of ask. With
|
|
// AI_ENABLED false the tab is not in the tab list and the scheduler dock is not rendered, so
|
|
// nothing in the UI can legitimately reach any action here — including the cheap reads, which
|
|
// would otherwise still answer with index and token figures for a subsystem the operator has
|
|
// turned off. Not a 404: the switch is a setting, and the message names the setting.
|
|
if (!vv_ai_enabled()) {
|
|
echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']);
|
|
exit;
|
|
}
|
|
|
|
// ── 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 next 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') {
|
|
echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]);
|
|
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;
|
|
}
|
|
|
|
// ── 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') {
|
|
echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'chat_get') {
|
|
$chat = vv_ai_chat_read(trim($_GET['id'] ?? ''));
|
|
if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; }
|
|
echo json_encode(['ok' => true, 'chat' => $chat]);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'chat_save') {
|
|
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
|
|
|
$profile = trim($_POST['profile'] ?? 'chat');
|
|
if (!vv_ai_profile_ok($profile)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
|
|
}
|
|
|
|
$clean = [];
|
|
$msgs = json_decode($_POST['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($_POST['scope'] ?? '');
|
|
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
|
|
|
|
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope);
|
|
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
|
|
: 'FAILED: ' . $r['error']));
|
|
echo json_encode($r);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'chat_delete') {
|
|
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
|
$ok = vv_ai_chat_delete(trim($_POST['id'] ?? ''));
|
|
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']);
|
|
exit;
|
|
}
|
|
|
|
// ── 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') {
|
|
echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]);
|
|
exit;
|
|
}
|
|
if ($action === 'bug_close') {
|
|
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
|
$ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1');
|
|
echo json_encode(['ok' => $ok]);
|
|
exit;
|
|
}
|
|
|
|
// ── 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 dirname(__DIR__) . '/include/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 open list.
|
|
$rows = [];
|
|
$open = 0; $needs = 0;
|
|
foreach (vv_ai_findings_list(($_GET['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;
|
|
}
|
|
echo json_encode(['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)]]);
|
|
exit;
|
|
}
|
|
|
|
// 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) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
|
|
|
$fid = trim($_POST['id'] ?? '');
|
|
$act = trim($_POST['act'] ?? '');
|
|
$r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? ''));
|
|
vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
|
|
$r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
|
|
echo json_encode($r);
|
|
exit;
|
|
}
|
|
|
|
// ── 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) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
|
echo json_encode(vv_ai_incident_add(
|
|
trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? '')));
|
|
exit;
|
|
}
|
|
|
|
// ── ask ───────────────────────────────────────────────────────────────────────
|
|
if ($action === 'ask') {
|
|
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); 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 (!vv_ai_profile_ok($profile)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
|
|
}
|
|
$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($_POST['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($_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) . ' '
|
|
. 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(($_POST['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)));
|
|
|
|
echo json_encode(['ok' => true, 'token' => $token]);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|