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. + + + + + + + + All sources + README — what things are + Manual — how to do things + Script headers + Conf templates + Design notes + + reasoning + Clear + Ctrl+Enter to send · history capped at 3 turns + Ask + + + + + + + + + Close + + + + + +