From 264ba57cbb2d9e4b9fc197235d6353c7aba3d95b Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 2 Aug 2026 17:32:17 -0400 Subject: [PATCH] =?UTF-8?q?Add=20the=20AI=20tab=20=E2=80=94=20grounded=20c?= =?UTF-8?q?hat=20over=20the=20documentation=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token and poll rather than SSE, so the api layer keeps one response convention and reuses the pattern manual_sync already proved. History is capped at three turns because the model is only fully offloaded at 16384 context and unbounded history would cross that silently. The tab exists only while AI_ENABLED is true, rejected server-side and not merely hidden. --- Plugin/unraid/Tools/ai_chat_worker.php | 196 ++++++++++++ Plugin/unraid/Varaverk.page | 9 +- Plugin/unraid/api/ai.php | 187 ++++++++++++ Plugin/unraid/api/readscript.php | 15 +- Plugin/unraid/include/ai.php | 251 ++++++++++++++++ Plugin/unraid/pages/ai.php | 400 +++++++++++++++++++++++++ 6 files changed, 1053 insertions(+), 5 deletions(-) create mode 100644 Plugin/unraid/Tools/ai_chat_worker.php create mode 100644 Plugin/unraid/api/ai.php create mode 100644 Plugin/unraid/include/ai.php create mode 100644 Plugin/unraid/pages/ai.php diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php new file mode 100644 index 0000000..6a47064 --- /dev/null +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -0,0 +1,196 @@ + generating -> done | error. +// +// Retrieval happens here rather than in the endpoint so the tab gets a token immediately. +// Embedding a query is fast but not free, and it is the first thing that would make the +// "send" button feel slow. +// +// DESIGN PRINCIPLES +// Context is assembled here, not by the model's own tooling. +// The retrieved chunks go into a system message with explicit citation and refusal +// instructions. That instruction is the difference between a grounded answer and the +// model filling a gap from training data it does not have for a private project. +// +// History arrives already trimmed. +// The endpoint caps turns before spawning. The worker does not re-derive the policy, +// so there is one place that decides how much context history may consume. +// +// Thinking is captured separately, never discarded. +// qwen3 emits reasoning that is often more useful than the answer for judgement calls. +// It is stored in its own field so the page can collapse it rather than lose it. +// +// OPERATIONAL SAFEGUARDS +// Refuses to run under a web server. +// PHP_SAPI is checked first. Over HTTP there is no $argv, so every argument below would +// be undefined — and this process talks to Ollama and writes job files. +// +// The job file path is validated as hex before anything is written. +// It is supplied on the command line; the pattern is what keeps writes inside the job +// directory even if the caller is ever wrong. +// +// The Ollama request is time-boxed. +// AI_REQUEST_TIMEOUT bounds it, and a timeout is written as an error state rather than +// leaving the job file at "generating" forever. +// +// Retrieval failure ends the turn. +// An empty or failed retrieval writes an error instead of asking the model anyway. A +// generated answer with no grounding is exactly the confident hallucination this whole +// subsystem exists to prevent. +// +// Every failure path writes the job file. +// Including the ones that would otherwise be silent — unreachable Ollama, unparseable +// response, empty content — so the tab always converges on a state it can render. +// +// ARGUMENTS +// 1 jobFile absolute path, hex-named, written by api/ai.php +// 2 question the user's message +// 3 history JSON array of {role, content}, already trimmed by the endpoint +// 4 kind optional retrieval filter (header|readme|manual|template|doc) +// 5 think "1" to allow the model's reasoning, "0" to suppress it +// +// JOB FILE STATES +// {"status":"retrieving"} +// {"status":"generating","sources":[…]} +// {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}} +// {"status":"error","error":…} +// ═══════════════════════════════════════════════════════════════════════════════════════════════ + +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit(1); +} + +require_once dirname(__DIR__) . '/include/ai.php'; + +[$jobFile, $question, $historyJson, $kind, $think] = array_slice($argv, 1, 5) + array_fill(0, 5, ''); + +if ($jobFile === '' || $question === '') exit(1); +if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); + +function jw(string $f, array $d): void { + file_put_contents($f, json_encode($d)); +} + +$cfg = vv_ai_config(); +$t0 = microtime(true); + +jw($jobFile, ['status' => 'retrieving']); + +$r = vv_ai_retrieve($question, $kind); +if (!$r['ok']) { + jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']); + exit(1); +} +if (!$r['results']) { + jw($jobFile, ['status' => 'error', + 'error' => 'No relevant documentation found. Try rephrasing, or use the readme filter ' + . 'for questions about what something is.']); + exit(0); +} + +$tRetrieve = microtime(true) - $t0; + +$sources = array_map(fn($x) => [ + 'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '', + 'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0, +], $r['results']); + +jw($jobFile, ['status' => 'generating', 'sources' => $sources]); + +$context = ''; +foreach ($r['results'] as $i => $x) { + $label = implode(' › ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? ''])); + $context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n"; +} + +$system = "You are Varaverk's documentation assistant. Varaverk is this user's private " + . "two-server Unraid media ecosystem; it is not in your training data, so the passages " + . "below are the only thing you know about it.\n\n" + . "Answer only from these passages and cite them inline as [1], [2]. If they do not " + . "contain the answer, say so plainly and name what is missing — do not fill the gap " + . "from general knowledge. Prefer the user's own terminology.\n\n" + . "PASSAGES\n" . $context; + +$messages = [['role' => 'system', 'content' => $system]]; +$hist = json_decode($historyJson ?: '[]', 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 !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } +} +$messages[] = ['role' => 'user', 'content' => $question]; + +$payload = json_encode([ + 'model' => $cfg['model'], + 'messages' => $messages, + 'stream' => false, + 'think' => $think === '1', + 'options' => ['num_ctx' => 16384], +]); + +$t1 = microtime(true); +$ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => max(30, $cfg['timeout']), + 'ignore_errors' => true, +]]); + +$raw = @file_get_contents($cfg['url'] . '/api/chat', false, $ctx); +if ($raw === false) { + jw($jobFile, ['status' => 'error', + 'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'], + 'sources' => $sources]); + exit(1); +} + +$d = json_decode($raw, true); +if (!is_array($d) || !isset($d['message'])) { + jw($jobFile, ['status' => 'error', 'error' => 'Unparseable response from Ollama', + 'sources' => $sources]); + exit(1); +} + +$answer = trim((string)($d['message']['content'] ?? '')); +$thinking = trim((string)($d['message']['thinking'] ?? '')); + +if ($answer === '') { + jw($jobFile, ['status' => 'error', + 'error' => 'The model returned no answer' . ($thinking !== '' ? ' (only reasoning)' : ''), + 'thinking' => $thinking, 'sources' => $sources]); + exit(1); +} + +$evalCount = (int)($d['eval_count'] ?? 0); +$evalNs = (int)($d['eval_duration'] ?? 0); + +jw($jobFile, [ + 'status' => 'done', + 'answer' => $answer, + 'thinking' => $thinking, + 'sources' => $sources, + 'timing' => [ + 'retrieve_ms' => (int)round($tRetrieve * 1000), + 'generate_ms' => (int)round((microtime(true) - $t1) * 1000), + 'tokens' => $evalCount, + 'tok_s' => $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null, + ], +]); diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index 49da25a..d3da024 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -73,8 +73,15 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing); // Determine active tab $tab = $_GET['tab'] ?? 'monitor'; $validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings']; + +// The AI tab exists only while AI_ENABLED is true. Appended to $validTabs rather than filtered +// out of it, so the check below rejects ?tab=ai server-side as well — omitting the link is +// presentation, not access control, and api/ai.php refuses `ask` on the same flag independently. +$_vv_ai = strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) === 'true'; +if ($_vv_ai) $validTabs[] = 'ai'; + if (!in_array($tab, $validTabs)) $tab = 'monitor'; -$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings']; +$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings', 'ai' => 'AI']; ?> diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php new file mode 100644 index 0000000..2d67f00 --- /dev/null +++ b/Plugin/unraid/api/ai.php @@ -0,0 +1,187 @@ + job state +// POST action=ask question=… [history=] [kind=…] [think=0|1] +// POST action=clear token= discard a finished job +// +// RESPONSE +// stats {"ok":true,"stats":{…}} +// ask {"ok":true,"token":""} +// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}} +// clear {"ok":true} +// {"ok":false,"error":…} +// +// DEPENDS ON +// include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*() +// Tools/ai_chat_worker.php the detached worker +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +header('Content-Type: application/json'); +header('Cache-Control: no-store, no-cache'); +require_once dirname(__DIR__) . '/include/ai.php'; + +const VV_AI_MAX_TURNS = 3; // user+assistant pairs retained; see OPERATIONAL MODEL +const VV_AI_MAX_QUESTION = 4000; // characters +const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message +const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped + +$isPost = $_SERVER['REQUEST_METHOD'] === 'POST'; +$action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats')); + +// ── stats ───────────────────────────────────────────────────────────────────── +if ($action === 'stats') { + echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]); + exit; +} + +// ── poll ────────────────────────────────────────────────────────────────────── +if ($action === 'poll') { + $token = trim($_GET['token'] ?? ''); + if (vv_ai_job_path($token) === null) { + echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; + } + $job = vv_ai_job_read($token); + if ($job === null) { + // The worker writes its first state after this request may already have arrived. + echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit; + } + echo json_encode(['ok' => true, 'job' => $job]); + exit; +} + +// ── clear ───────────────────────────────────────────────────────────────────── +if ($action === 'clear') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + $p = vv_ai_job_path(trim($_POST['token'] ?? '')); + if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } + if (file_exists($p)) @unlink($p); + echo json_encode(['ok' => true]); + exit; +} + +// ── ask ─────────────────────────────────────────────────────────────────────── +if ($action === 'ask') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + + if (!vv_ai_enabled()) { + echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']); exit; + } + + $cfg = vv_ai_config(); + if ($cfg['model'] === '') { + echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit; + } + + $question = trim($_POST['question'] ?? ''); + if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; } + if (mb_strlen($question) > VV_AI_MAX_QUESTION) { + echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit; + } + + $kind = trim($_POST['kind'] ?? ''); + if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { + echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit; + } + + // Validate per message rather than trusting the blob: a crafted history could otherwise + // inject a system role, or push the context past the offload ceiling. + $clean = []; + $hist = json_decode($_POST['history'] ?? '[]', true); + if (is_array($hist)) { + foreach ($hist as $m) { + $role = $m['role'] ?? ''; + $text = trim((string)($m['content'] ?? '')); + if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue; + $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)]; + } + } + if (count($clean) > VV_AI_MAX_TURNS * 2) { + $clean = array_slice($clean, -(VV_AI_MAX_TURNS * 2)); + } + + $dir = vv_ai_job_dir(); + foreach (glob($dir . '/*.json') ?: [] as $old) { + if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old); + } + + $token = bin2hex(random_bytes(16)); + $jobFile = vv_ai_job_path($token); + $worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php'; + + if (!file_exists($worker)) { + echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit; + } + + @file_put_contents($jobFile, json_encode(['status' => 'pending'])); + + $cmd = 'nohup php ' . escapeshellarg($worker) . ' ' + . escapeshellarg($jobFile) . ' ' + . escapeshellarg($question) . ' ' + . escapeshellarg(json_encode($clean)) . ' ' + . escapeshellarg($kind) . ' ' + . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') + . ' >/dev/null 2>&1 true, 'token' => $token]); + exit; +} + +echo json_encode(['ok' => false, 'error' => 'Unknown action']); diff --git a/Plugin/unraid/api/readscript.php b/Plugin/unraid/api/readscript.php index 3bdd9cc..f2bd62f 100644 --- a/Plugin/unraid/api/readscript.php +++ b/Plugin/unraid/api/readscript.php @@ -31,9 +31,16 @@ // implied by the character class. // // The extension allowlist is the real access boundary. -// Only .sh and .md can be named at all, which is what keeps Configurations/*.conf — -// the files holding every credential in the system — outside this endpoint's reach. Any -// future extension added here has to be checked against that first. +// Only .sh, .md, .php and .template can be named at all, which is what keeps +// Configurations/*.conf — the files holding every credential in the system — outside +// this endpoint's reach. Any future extension added here has to be checked against that +// first. +// +// .php and .template were added for the AI tab's source viewer, whose retrieval results +// span every tracked file type. They are safe by the same argument that makes the AI +// index safe: only git-tracked content is involved, the conf files were never tracked, +// and the repository is pushed to a remote — anything reachable here is already +// published. .conf is deliberately still absent, and must stay that way. // // A missing file is reported, not opened. // file_exists() precedes file_get_contents(), so a bad id returns a named error rather @@ -62,7 +69,7 @@ require_once dirname(__DIR__) . '/include/config.php'; $id = trim($_GET['id'] ?? ''); // Must be relative path within SCRIPTS_DIR, no traversal, must end in .sh or .md -if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.(sh|md)$/', $id)) { +if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.(sh|md|php|template)$/', $id)) { echo json_encode(['ok' => false, 'error' => 'Invalid id']); exit; } diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php new file mode 100644 index 0000000..d9ffad6 --- /dev/null +++ b/Plugin/unraid/include/ai.php @@ -0,0 +1,251 @@ + strtolower(trim($vars['AI_ENABLED'] ?? 'false')) === 'true', + 'url' => rtrim(trim($vars["{$host}_OLLAMA_URL"] ?? ''), '/'), + 'model' => trim($vars["{$host}_OLLAMA_MODEL"] ?? ''), + 'embed_model' => trim($vars["{$host}_OLLAMA_EMBED_MODEL"] ?? 'nomic-embed-text'), + 'db' => trim($vars['AI_INDEX_DB'] ?? '') ?: DATA_DIR . '/ai_index.db', + 'k' => (int)($vars['AI_SEARCH_K'] ?? 8), + 'per_file' => (int)($vars['AI_SEARCH_PER_FILE'] ?? 3), + 'timeout' => (int)($vars['AI_REQUEST_TIMEOUT'] ?? 240), + 'connect' => (int)($vars['AI_CONNECT_TIMEOUT'] ?? 5), + ]; + // AI_INDEX_DB is written as "$DATA_DIR/ai_index.db" in conf; the shell expands it, PHP does + // not. Left as a literal it would name a file that cannot exist. + $cfg['db'] = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $cfg['db']); + return $cfg; +} + +function vv_ai_enabled(): bool { + return vv_ai_config()['enabled']; +} + +// Index size, coverage and staleness. Staleness compares the newest git-tracked file against +// the last build: the indexer only ever ingests tracked content, so anything else would report +// permanent drift. +function vv_ai_index_stats(): array { + $cfg = vv_ai_config(); + $out = ['exists' => false, 'chunks' => 0, 'files' => 0, 'size' => 0, + 'built' => null, 'newest_source' => null, 'stale' => null, 'kinds' => []]; + + if (!file_exists($cfg['db'])) return $out; + $out['exists'] = true; + $out['size'] = (int)@filesize($cfg['db']); + + $db = escapeshellarg($cfg['db']); + $q = fn(string $sql) => trim((string)@shell_exec('sqlite3 ' . $db . ' ' . escapeshellarg($sql) . ' 2>/dev/null')); + + $out['chunks'] = (int)$q('SELECT COUNT(*) FROM vv_chunks;'); + $out['files'] = (int)$q('SELECT COUNT(*) FROM vv_files;'); + $built = (int)$q('SELECT MAX(indexed) FROM vv_files;'); + $out['built'] = $built ?: null; + + foreach (explode("\n", $q('SELECT kind, COUNT(*) FROM vv_chunks GROUP BY kind;')) as $line) { + if (!$line) continue; + [$k, $n] = array_pad(explode('|', $line, 2), 2, 0); + if ($k !== '') $out['kinds'][$k] = (int)$n; + } + + $newest = trim((string)@shell_exec( + 'cd ' . escapeshellarg(SCRIPTS_DIR) . ' && git ls-files -z 2>/dev/null' + . ' | xargs -0 stat -c %Y 2>/dev/null | sort -rn | head -1' + )); + if ($newest !== '') { + $out['newest_source'] = (int)$newest; + if ($out['built'] !== null) $out['stale'] = (int)$newest > $out['built']; + } + return $out; +} + +// Ollama reachability and — the number that matters on this hardware — whether the generation +// model is fully resident on the GPU. size_vram below size means layers are on the CPU, which +// costs roughly 4x throughput and is invisible everywhere else. +function vv_ai_runtime_stats(): array { + $cfg = vv_ai_config(); + $out = ['reachable' => false, 'loaded' => null, 'offload_pct' => null, + 'context' => null, 'vram_used' => null, 'vram_total' => null, 'gpu' => null]; + + if ($cfg['url'] === '') return $out; + + $ctx = stream_context_create(['http' => [ + 'method' => 'GET', 'timeout' => max(2, $cfg['connect']), 'ignore_errors' => true, + ]]); + $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx); + if ($raw === false) return $out; + $out['reachable'] = true; + + $ps = json_decode($raw, true); + foreach ($ps['models'] ?? [] as $m) { + if (($m['name'] ?? '') !== $cfg['model']) continue; + $size = (int)($m['size'] ?? 0); + $vram = (int)($m['size_vram'] ?? 0); + $out['loaded'] = true; + $out['context'] = $m['context_length'] ?? null; + $out['vram_used'] = $vram; + $out['vram_total'] = $size; + $out['offload_pct'] = $size > 0 ? (int)round($vram / $size * 100) : null; + break; + } + if ($out['loaded'] === null) $out['loaded'] = false; + + $gpu = trim((string)@shell_exec( + 'nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu ' + . '--format=csv,noheader,nounits 2>/dev/null | tail -1' + )); + if ($gpu !== '') { + $p = array_map('trim', explode(',', $gpu)); + if (count($p) >= 4) { + $out['gpu'] = ['name' => $p[0], 'mem_used' => (int)$p[1], + 'mem_total' => (int)$p[2], 'util' => (int)$p[3]]; + } + } + return $out; +} + +function vv_ai_stats(): array { + $cfg = vv_ai_config(); + return [ + 'enabled' => $cfg['enabled'], + 'model' => $cfg['model'], + 'embed_model' => $cfg['embed_model'], + 'url' => $cfg['url'], + 'k' => $cfg['k'], + 'index' => vv_ai_index_stats(), + 'runtime' => vv_ai_runtime_stats(), + 'ts' => time(), + ]; +} + +// Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string]. +function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array { + $cfg = vv_ai_config(); + + if ($query === '') return ['ok' => false, 'error' => 'query is required', 'results' => []]; + if ($cfg['url'] === '') return ['ok' => false, 'error' => 'Ollama URL is not configured', 'results' => []]; + if (!file_exists($cfg['db'])) return ['ok' => false, 'error' => 'No index — run AI/ai_index.sh', 'results' => []]; + if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { + return ['ok' => false, 'error' => 'Unknown kind: ' . $kind, 'results' => []]; + } + + $cli = SCRIPTS_DIR . '/AI/lib/cli.js'; + if (!file_exists($cli)) return ['ok' => false, 'error' => 'AI/lib/cli.js not found', 'results' => []]; + + $k = max(1, min($k ?? $cfg['k'], 25)); + + $cmd = 'timeout ' . max(10, $cfg['connect'] * 6) . ' node --no-warnings ' + . escapeshellarg($cli) . ' search' + . ' --db=' . escapeshellarg($cfg['db']) + . ' --url=' . escapeshellarg($cfg['url']) + . ' --model=' . escapeshellarg($cfg['embed_model']) + . ' --query=' . escapeshellarg($query) + . ' --k=' . $k + . ' --per-file=' . max(1, $cfg['per_file']) + . ($kind !== '' ? ' --kind=' . escapeshellarg($kind) : '') + . ($section !== '' ? ' --section=' . escapeshellarg($section) : '') + . ' --json 2>/dev/null'; + + $raw = trim((string)@shell_exec($cmd)); + if ($raw === '') return ['ok' => false, 'error' => 'Retrieval produced no output', 'results' => []]; + + $d = json_decode($raw, true); + if (!is_array($d) || !isset($d['results'])) { + return ['ok' => false, 'error' => 'Retrieval returned unparseable output', 'results' => []]; + } + return ['ok' => true, 'results' => $d['results'], 'intents' => $d['intents'] ?? [], + 'scanned' => $d['scanned'] ?? 0]; +} + +function vv_ai_job_dir(): string { + if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true); + return VV_AI_JOB_DIR; +} + +// Hex-only, fixed length. The token composes a path, so the pattern is what confines it to the +// job directory. +function vv_ai_job_path(string $token): ?string { + if (!preg_match('/^[0-9a-f]{32}$/', $token)) return null; + return VV_AI_JOB_DIR . '/' . $token . '.json'; +} + +function vv_ai_job_read(string $token): ?array { + $p = vv_ai_job_path($token); + if ($p === null || !file_exists($p)) return null; + $d = json_decode(@file_get_contents($p) ?: '', true); + return is_array($d) ? $d : null; +} diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php new file mode 100644 index 0000000..d5aa44b --- /dev/null +++ b/Plugin/unraid/pages/ai.php @@ -0,0 +1,400 @@ + + + +
+ +
+ +
+
+ Ask Varaverk about itself.
+ Answers come only from this installation's own documentation, with sources. +
+
+ +
+ +
+ + + + Ctrl+Enter to send · history capped at 3 turns + +
+
+
+ +
+
+
+ + +
+

+  
+
+ +