Add the AI tab — grounded chat over the documentation index
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.
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PURPOSE
|
||||||
|
// Detached worker for one AI chat turn. Retrieves grounding chunks, asks the generation
|
||||||
|
// model, and writes progress and the final answer to a job file the AI tab polls.
|
||||||
|
//
|
||||||
|
// OPERATIONAL MODEL
|
||||||
|
// Not an HTTP endpoint. Generation takes 25-76 seconds on this hardware — far past what a
|
||||||
|
// page request should hold open — so api/ai.php spawns this detached and returns a token.
|
||||||
|
// The job file is the only channel between the two, exactly as docker_pull_worker.php works
|
||||||
|
// for container updates. It lives under api/'s sibling Tools/ because it is part of that
|
||||||
|
// endpoint's implementation, not a scheduled script.
|
||||||
|
//
|
||||||
|
// Writes a terminal state on every exit path. A worker that dies without one leaves the tab
|
||||||
|
// polling forever, so the states are: retrieving -> 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -73,8 +73,15 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
|||||||
// Determine active tab
|
// Determine active tab
|
||||||
$tab = $_GET['tab'] ?? 'monitor';
|
$tab = $_GET['tab'] ?? 'monitor';
|
||||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
$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';
|
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'];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?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
|
||||||
|
// ask is refused when AI is disabled.
|
||||||
|
// AI_ENABLED gates the whole subsystem; the tab is hidden when it is false, but hiding
|
||||||
|
// a link is not access control and the endpoint is reachable directly.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
|
||||||
|
// POST action=clear token=<hex32> discard a finished job
|
||||||
|
//
|
||||||
|
// RESPONSE
|
||||||
|
// stats {"ok":true,"stats":{…}}
|
||||||
|
// ask {"ok":true,"token":"<hex32>"}
|
||||||
|
// 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 </dev/null &';
|
||||||
|
exec($cmd);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'token' => $token]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||||
@@ -31,9 +31,16 @@
|
|||||||
// implied by the character class.
|
// implied by the character class.
|
||||||
//
|
//
|
||||||
// The extension allowlist is the real access boundary.
|
// The extension allowlist is the real access boundary.
|
||||||
// Only .sh and .md can be named at all, which is what keeps Configurations/*.conf —
|
// Only .sh, .md, .php and .template can be named at all, which is what keeps
|
||||||
// the files holding every credential in the system — outside this endpoint's reach. Any
|
// Configurations/*.conf — the files holding every credential in the system — outside
|
||||||
// future extension added here has to be checked against that first.
|
// 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.
|
// A missing file is reported, not opened.
|
||||||
// file_exists() precedes file_get_contents(), so a bad id returns a named error rather
|
// 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'] ?? '');
|
$id = trim($_GET['id'] ?? '');
|
||||||
|
|
||||||
// Must be relative path within SCRIPTS_DIR, no traversal, must end in .sh or .md
|
// 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']);
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PURPOSE
|
||||||
|
// The AI tab's library. Resolves AI configuration, reports the health of the retrieval
|
||||||
|
// subsystem for the status banner, runs retrieval against the index, and owns the job-file
|
||||||
|
// contract the chat worker and the polling endpoint share.
|
||||||
|
//
|
||||||
|
// OPERATIONAL MODEL
|
||||||
|
// Retrieval is delegated, not reimplemented. AI/lib/search.js already embeds the query,
|
||||||
|
// scans the index and applies intent routing; this file shells to that CLI with --json. A
|
||||||
|
// second PHP implementation of vector search would be a second set of scoring bugs, and the
|
||||||
|
// two would drift the first time the chunker changed.
|
||||||
|
//
|
||||||
|
// Generation is not performed here. It takes 25-76 seconds and belongs in a detached worker;
|
||||||
|
// this file only describes where that worker writes and what the states mean.
|
||||||
|
//
|
||||||
|
// DESIGN PRINCIPLES
|
||||||
|
// Every reported statistic distinguishes "unknown" from "bad".
|
||||||
|
// A banner that shows a healthy default when it cannot reach Ollama is worse than one
|
||||||
|
// that shows nothing, because the operator stops checking. Each probe returns null when
|
||||||
|
// it could not determine the answer.
|
||||||
|
//
|
||||||
|
// Offload percentage is a first-class metric.
|
||||||
|
// size_vram against size is the difference between 74 tok/s and 19 on this hardware, and
|
||||||
|
// nothing else on the host surfaces it. It is computed here so the banner and any future
|
||||||
|
// watchdog read the same number.
|
||||||
|
//
|
||||||
|
// Staleness is measured against tracked files, not the filesystem.
|
||||||
|
// The index only ever contains git-tracked content, so comparing to an untracked scratch
|
||||||
|
// file would report permanent staleness. git ls-files is the same source the indexer uses.
|
||||||
|
//
|
||||||
|
// OPERATIONAL SAFEGUARDS
|
||||||
|
// Read-only. Nothing here writes to the index, the conf, or the job files — it reads state
|
||||||
|
// and runs a retrieval query. The worker owns every write.
|
||||||
|
//
|
||||||
|
// Every external call is time-boxed.
|
||||||
|
// Retrieval and the Ollama probes carry explicit timeouts, because this library is
|
||||||
|
// loaded by a page render and by a polling endpoint; a hung Ollama must degrade the
|
||||||
|
// banner, not hang the tab.
|
||||||
|
//
|
||||||
|
// Retrieval arguments are validated before they reach the shell.
|
||||||
|
// kind is checked against the five real values and k is clamped, then every value is
|
||||||
|
// escapeshellarg'd. An unknown kind would match zero rows in SQL and read as "the index
|
||||||
|
// has no answer", which is the most misleading failure this subsystem can produce.
|
||||||
|
//
|
||||||
|
// Tokens are validated as hex before they compose a path.
|
||||||
|
// Job files live in a fixed directory and are named from a caller-supplied token; the
|
||||||
|
// pattern is what keeps that token from escaping the directory.
|
||||||
|
//
|
||||||
|
// A missing index is reported, never treated as empty.
|
||||||
|
// Empty results and an absent index look identical to a caller that only counts rows,
|
||||||
|
// and they need completely different responses from the user.
|
||||||
|
//
|
||||||
|
// EXPORTS
|
||||||
|
// Config vv_ai_enabled(), vv_ai_config()
|
||||||
|
// Status vv_ai_stats(), vv_ai_index_stats(), vv_ai_runtime_stats()
|
||||||
|
// Retrieval vv_ai_retrieve()
|
||||||
|
// Jobs vv_ai_job_dir(), vv_ai_job_path(), vv_ai_job_read()
|
||||||
|
//
|
||||||
|
// CONFIGURATION
|
||||||
|
// master.conf AI_ENABLED, AI_INDEX_DB, AI_SEARCH_K, AI_SEARCH_PER_FILE,
|
||||||
|
// AI_REQUEST_TIMEOUT, AI_CONNECT_TIMEOUT
|
||||||
|
// host*.conf HOST*_OLLAMA_URL, HOST*_OLLAMA_MODEL, HOST*_OLLAMA_EMBED_MODEL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs');
|
||||||
|
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc'];
|
||||||
|
|
||||||
|
function vv_ai_config(): array {
|
||||||
|
static $cfg = null;
|
||||||
|
if ($cfg !== null) return $cfg;
|
||||||
|
|
||||||
|
$vars = vv_conf_vars();
|
||||||
|
$host = strtoupper(vv_detect_host());
|
||||||
|
|
||||||
|
$cfg = [
|
||||||
|
'enabled' => 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PURPOSE
|
||||||
|
// AI tab. Asks Varaverk about itself — a grounded chat over the documentation index, with a
|
||||||
|
// status banner covering index health, model residency and GPU state.
|
||||||
|
//
|
||||||
|
// OPERATIONAL MODEL
|
||||||
|
// Token and poll, not streaming. A turn takes 25-76 seconds, so the composer POSTs to
|
||||||
|
// api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
|
||||||
|
// the api layer on one response convention; see api/ai.php for why SSE was declined.
|
||||||
|
//
|
||||||
|
// The tab is only reachable when AI_ENABLED is true. Varaverk.page omits it from the tab
|
||||||
|
// list and rejects it server-side, and api/ai.php refuses ask independently — hiding a link
|
||||||
|
// is not access control.
|
||||||
|
//
|
||||||
|
// DESIGN PRINCIPLES
|
||||||
|
// The banner leads with offload, not with size.
|
||||||
|
// 41/41 layers at 100% GPU is the difference between 74 tok/s and 19 on this card, and
|
||||||
|
// nothing else in the WebGUI surfaces it. Index size is interesting; residency is
|
||||||
|
// actionable.
|
||||||
|
//
|
||||||
|
// Staleness is stated, not implied.
|
||||||
|
// An index older than the newest tracked file will answer confidently from code that has
|
||||||
|
// since changed — the one failure a grounded answer cannot reveal on its own.
|
||||||
|
//
|
||||||
|
// Sources are the point, not a footnote.
|
||||||
|
// Every answer lists what it was built from, with scores, and each source opens the file
|
||||||
|
// it came from. Retrieval you can audit is the reason to build this here rather than use
|
||||||
|
// a general chat client.
|
||||||
|
//
|
||||||
|
// Reasoning is kept and collapsed.
|
||||||
|
// qwen3 emits substantial thinking, often more useful than the answer for a judgement
|
||||||
|
// call. Hidden by default so it does not bury the answer; one click away because
|
||||||
|
// discarding it would lose the best part.
|
||||||
|
//
|
||||||
|
// OPERATIONAL SAFEGUARDS
|
||||||
|
// Every rendered string is escaped before it reaches the DOM.
|
||||||
|
// Answers, reasoning, source paths and headings are all model or file derived. The
|
||||||
|
// minimal markdown pass runs strictly after escaping, so no input can introduce markup.
|
||||||
|
//
|
||||||
|
// Conversation history is bounded client-side and again server-side.
|
||||||
|
// The page sends the last few turns; api/ai.php caps them regardless. The model is only
|
||||||
|
// fully offloaded at 16384 context, and unbounded history would cross that silently.
|
||||||
|
//
|
||||||
|
// Polling stops on a terminal state, on error, and on a wall-clock ceiling.
|
||||||
|
// A worker that dies without writing would otherwise be polled forever.
|
||||||
|
//
|
||||||
|
// Read-only with respect to the system. Nothing here runs a script, edits conf, or changes
|
||||||
|
// any Varaverk state — it asks questions about documentation.
|
||||||
|
//
|
||||||
|
// RENDERS
|
||||||
|
// Status banner (index, model residency, GPU, staleness), chat transcript with collapsible
|
||||||
|
// reasoning and audited sources, composer with retrieval-scope and reasoning controls,
|
||||||
|
// source viewer overlay
|
||||||
|
//
|
||||||
|
// DEPENDS ON
|
||||||
|
// api/ai.php stats / ask / poll / clear
|
||||||
|
// api/readscript.php source viewer contents
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
#vv-ai-wrap { display:flex; flex-direction:column; gap:12px; }
|
||||||
|
|
||||||
|
/* ── Banner ─────────────────────────────────────────────────────────────── */
|
||||||
|
.vv-ai-banner { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1px;
|
||||||
|
background:#1a1a1a; border:1px solid #262626; border-radius:6px; overflow:hidden; }
|
||||||
|
.vv-ai-stat { background:#0e0e0e; padding:10px 12px; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.vv-ai-stat-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
|
||||||
|
.vv-ai-stat-v { font-size:15px; font-weight:bold; color:#c8c8c8; font-family:monospace; }
|
||||||
|
.vv-ai-stat-s { font-size:10px; color:#5a5a5a; }
|
||||||
|
.vv-ai-ok { color:#6fcf97 !important; }
|
||||||
|
.vv-ai-warn { color:#ffb74d !important; }
|
||||||
|
.vv-ai-bad { color:#e57 !important; }
|
||||||
|
|
||||||
|
/* ── Chat ───────────────────────────────────────────────────────────────── */
|
||||||
|
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
|
||||||
|
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||||||
|
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
|
||||||
|
.vv-ai-msg { margin-bottom:16px; }
|
||||||
|
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
|
||||||
|
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
|
||||||
|
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
|
||||||
|
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
|
||||||
|
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
|
||||||
|
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
|
||||||
|
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
|
||||||
|
overflow-x:auto; margin:8px 0; }
|
||||||
|
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
|
||||||
|
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
|
||||||
|
.vv-ai-cite:hover { text-decoration:underline; }
|
||||||
|
|
||||||
|
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
|
||||||
|
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
|
||||||
|
.vv-ai-think-t:hover { color:#777; border-color:#333; }
|
||||||
|
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
|
||||||
|
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; }
|
||||||
|
.vv-ai-think.open { display:block; }
|
||||||
|
|
||||||
|
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
|
||||||
|
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; margin-bottom:4px; }
|
||||||
|
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
|
||||||
|
.vv-ai-src-i:hover { color:#8a8a8a; }
|
||||||
|
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
|
||||||
|
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
|
||||||
|
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
|
||||||
|
|
||||||
|
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
|
||||||
|
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
|
||||||
|
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||||||
|
|
||||||
|
/* ── Composer ───────────────────────────────────────────────────────────── */
|
||||||
|
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
||||||
|
border-radius:6px; padding:10px; background:#0e0e0e; }
|
||||||
|
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
|
||||||
|
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
|
||||||
|
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
|
||||||
|
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||||||
|
padding:4px 7px; border-radius:3px; }
|
||||||
|
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
|
||||||
|
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
|
||||||
|
border-radius:3px; cursor:pointer; }
|
||||||
|
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
|
||||||
|
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
|
||||||
|
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
|
||||||
|
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||||||
|
|
||||||
|
/* ── Source overlay ─────────────────────────────────────────────────────── */
|
||||||
|
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
|
||||||
|
padding:36px; }
|
||||||
|
#vv-ai-view.open { display:block; }
|
||||||
|
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
|
||||||
|
display:flex; flex-direction:column; }
|
||||||
|
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
|
||||||
|
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
|
||||||
|
color:#9a9a9a; white-space:pre; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="vv-ai-wrap">
|
||||||
|
|
||||||
|
<div class="vv-ai-banner" id="vv-ai-banner"></div>
|
||||||
|
|
||||||
|
<div class="vv-ai-chat" id="vv-ai-chat">
|
||||||
|
<div class="vv-ai-empty">
|
||||||
|
Ask Varaverk about itself.<br>
|
||||||
|
Answers come only from this installation's own documentation, with sources.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vv-ai-composer">
|
||||||
|
<textarea class="vv-ai-input" id="vv-ai-input" rows="2"
|
||||||
|
placeholder="e.g. what stops rsync and the mover running at once?"></textarea>
|
||||||
|
<div class="vv-ai-ctrls">
|
||||||
|
<select id="vv-ai-kind" title="Restrict retrieval to one kind of source">
|
||||||
|
<option value="">All sources</option>
|
||||||
|
<option value="readme">README — what things are</option>
|
||||||
|
<option value="manual">Manual — how to do things</option>
|
||||||
|
<option value="header">Script headers</option>
|
||||||
|
<option value="template">Conf templates</option>
|
||||||
|
<option value="doc">Design notes</option>
|
||||||
|
</select>
|
||||||
|
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> reasoning</label>
|
||||||
|
<button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
|
||||||
|
<span class="vv-ai-hint">Ctrl+Enter to send · history capped at 3 turns</span>
|
||||||
|
<button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
|
||||||
|
<div class="vv-ai-view-box">
|
||||||
|
<div class="vv-ai-view-h">
|
||||||
|
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
|
||||||
|
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
|
||||||
|
</div>
|
||||||
|
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const API = '/plugins/varaverk/api/ai.php';
|
||||||
|
const MAXTURN = 3;
|
||||||
|
const POLL_MS = 1200;
|
||||||
|
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||||||
|
|
||||||
|
let history = []; // {role, content} — trimmed to MAXTURN pairs
|
||||||
|
let busy = false;
|
||||||
|
let lastSources = [];
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const esc = s => String(s == null ? '' : s)
|
||||||
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
||||||
|
// ── Banner ──────────────────────────────────────────────────────────────
|
||||||
|
function stat(label, value, sub, cls) {
|
||||||
|
return `<div class="vv-ai-stat"><div class="vv-ai-stat-l">${esc(label)}</div>`
|
||||||
|
+ `<div class="vv-ai-stat-v ${cls||''}">${esc(value)}</div>`
|
||||||
|
+ `<div class="vv-ai-stat-s">${esc(sub||'')}</div></div>`;
|
||||||
|
}
|
||||||
|
function ago(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = Math.floor(Date.now()/1000) - ts;
|
||||||
|
if (d < 60) return 'just now';
|
||||||
|
if (d < 3600) return Math.floor(d/60)+'m ago';
|
||||||
|
if (d < 86400) return Math.floor(d/3600)+'h ago';
|
||||||
|
return Math.floor(d/86400)+'d ago';
|
||||||
|
}
|
||||||
|
function renderBanner(s) {
|
||||||
|
const ix = s.index || {}, rt = s.runtime || {};
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Residency first — it is the number that silently costs 4x throughput.
|
||||||
|
if (!rt.reachable) {
|
||||||
|
html += stat('Model', 'unreachable', s.url || '', 'vv-ai-bad');
|
||||||
|
} else if (!rt.loaded) {
|
||||||
|
html += stat('Model', 'not loaded', 'loads on first question', 'vv-ai-warn');
|
||||||
|
} else {
|
||||||
|
const p = rt.offload_pct;
|
||||||
|
html += stat('GPU offload', p === null ? '—' : p + '%',
|
||||||
|
p === 100 ? 'fully resident' : 'layers on CPU — slow',
|
||||||
|
p === 100 ? 'vv-ai-ok' : 'vv-ai-warn');
|
||||||
|
}
|
||||||
|
html += stat('Context', rt.context ? rt.context.toLocaleString() : '—',
|
||||||
|
(s.model || '').replace(/^hf\.co\/[^/]+\//,''));
|
||||||
|
|
||||||
|
if (!ix.exists) {
|
||||||
|
html += stat('Index', 'not built', 'run AI/ai_index.sh', 'vv-ai-bad');
|
||||||
|
} else {
|
||||||
|
html += stat('Index', ix.chunks.toLocaleString() + ' chunks',
|
||||||
|
ix.files + ' files · ' + (ix.size/1048576).toFixed(1) + ' MB');
|
||||||
|
html += stat('Built', ago(ix.built),
|
||||||
|
ix.stale ? 'source newer — reindex' : 'current',
|
||||||
|
ix.stale ? 'vv-ai-warn' : 'vv-ai-ok');
|
||||||
|
}
|
||||||
|
if (rt.gpu) {
|
||||||
|
html += stat('VRAM', (rt.gpu.mem_used/1024).toFixed(1) + '/' + (rt.gpu.mem_total/1024).toFixed(1) + ' GB',
|
||||||
|
rt.gpu.name + ' · ' + rt.gpu.util + '% util');
|
||||||
|
}
|
||||||
|
$('vv-ai-banner').innerHTML = html;
|
||||||
|
}
|
||||||
|
function loadBanner() {
|
||||||
|
fetch(API + '?action=stats').then(r => r.json())
|
||||||
|
.then(d => { if (d.ok) renderBanner(d.stats); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Minimal markdown, applied strictly after escaping ───────────────────
|
||||||
|
function fmt(text) {
|
||||||
|
let h = esc(text);
|
||||||
|
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
|
||||||
|
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||||
|
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" onclick="vvAiCite($1)">[$1]</span>');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transcript ──────────────────────────────────────────────────────────
|
||||||
|
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||||||
|
function chat() { return $('vv-ai-chat'); }
|
||||||
|
function scroll() { chat().scrollTop = chat().scrollHeight; }
|
||||||
|
function clearEmpty() { const e = chat().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||||||
|
|
||||||
|
function addUser(text) {
|
||||||
|
clearEmpty();
|
||||||
|
chat().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
|
||||||
|
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
|
||||||
|
scroll();
|
||||||
|
}
|
||||||
|
function addPending() {
|
||||||
|
const n = el(`<div class="vv-ai-msg bot" id="vv-ai-pending"><div class="vv-ai-role">Varaverk</div>`
|
||||||
|
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span><span id="vv-ai-phase">starting…</span></div></div>`);
|
||||||
|
chat().appendChild(n); scroll();
|
||||||
|
}
|
||||||
|
function phase(t) { const p = $('vv-ai-phase'); if (p) p.textContent = t; }
|
||||||
|
|
||||||
|
function sourcesHtml(sources) {
|
||||||
|
if (!sources || !sources.length) return '';
|
||||||
|
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
|
||||||
|
sources.forEach((s, i) => {
|
||||||
|
const label = [s.path, s.section, s.heading].filter(Boolean).join(' › ');
|
||||||
|
h += `<div class="vv-ai-src-i" onclick="vvAiOpen('${esc(s.path)}')">`
|
||||||
|
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||||||
|
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
|
||||||
|
});
|
||||||
|
return h + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAnswer(job) {
|
||||||
|
const p = $('vv-ai-pending'); if (p) p.remove();
|
||||||
|
lastSources = job.sources || [];
|
||||||
|
|
||||||
|
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
|
||||||
|
if (job.thinking) {
|
||||||
|
h += `<div class="vv-ai-think-t" onclick="this.nextElementSibling.classList.toggle('open')">`
|
||||||
|
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
|
||||||
|
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
|
||||||
|
}
|
||||||
|
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
|
||||||
|
h += sourcesHtml(job.sources);
|
||||||
|
const t = job.timing || {};
|
||||||
|
if (t.tok_s) {
|
||||||
|
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
|
||||||
|
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
|
||||||
|
}
|
||||||
|
chat().appendChild(el(h + '</div>')); scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addError(msg) {
|
||||||
|
const p = $('vv-ai-pending'); if (p) p.remove();
|
||||||
|
chat().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||||||
|
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
|
||||||
|
scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ask / poll ──────────────────────────────────────────────────────────
|
||||||
|
function send() {
|
||||||
|
if (busy) return;
|
||||||
|
const q = $('vv-ai-input').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
$('vv-ai-send').disabled = true;
|
||||||
|
addUser(q);
|
||||||
|
$('vv-ai-input').value = '';
|
||||||
|
addPending();
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'ask');
|
||||||
|
fd.append('question', q);
|
||||||
|
fd.append('history', JSON.stringify(history.slice(-MAXTURN * 2)));
|
||||||
|
fd.append('kind', $('vv-ai-kind').value);
|
||||||
|
fd.append('think', $('vv-ai-think').checked ? '1' : '0');
|
||||||
|
|
||||||
|
fetch(API, { method: 'POST', body: fd }).then(r => r.json()).then(d => {
|
||||||
|
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
|
||||||
|
history.push({ role: 'user', content: q });
|
||||||
|
poll(d.token, Date.now());
|
||||||
|
}).catch(e => { addError('Request failed: ' + e); finish(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() { busy = false; $('vv-ai-send').disabled = false; }
|
||||||
|
|
||||||
|
function poll(token, started) {
|
||||||
|
if (Date.now() - started > POLL_CEIL) {
|
||||||
|
addError('Timed out waiting for a response.'); finish(); return;
|
||||||
|
}
|
||||||
|
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
|
||||||
|
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
|
||||||
|
const j = d.job || {};
|
||||||
|
if (j.status === 'done') {
|
||||||
|
addAnswer(j);
|
||||||
|
history.push({ role: 'assistant', content: j.answer });
|
||||||
|
if (history.length > MAXTURN * 2) history = history.slice(-MAXTURN * 2);
|
||||||
|
const fd = new FormData(); fd.append('action','clear'); fd.append('token',token);
|
||||||
|
fetch(API, { method:'POST', body: fd }).catch(() => {});
|
||||||
|
finish(); loadBanner(); return;
|
||||||
|
}
|
||||||
|
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||||||
|
phase(j.status === 'generating'
|
||||||
|
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
|
||||||
|
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||||||
|
setTimeout(() => poll(token, started), POLL_MS);
|
||||||
|
}).catch(e => { addError('Poll failed: ' + e); finish(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source viewer ───────────────────────────────────────────────────────
|
||||||
|
window.vvAiOpen = function (path) {
|
||||||
|
$('vv-ai-view-t').textContent = path;
|
||||||
|
$('vv-ai-view-b').textContent = 'Loading…';
|
||||||
|
$('vv-ai-view').classList.add('open');
|
||||||
|
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { $('vv-ai-view-b').textContent = d.ok ? d.content
|
||||||
|
: (d.error || 'Could not read this file.'); })
|
||||||
|
.catch(e => { $('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
|
||||||
|
};
|
||||||
|
window.vvAiCloseView = function () { $('vv-ai-view').classList.remove('open'); };
|
||||||
|
window.vvAiCite = function (n) {
|
||||||
|
const s = lastSources[n - 1];
|
||||||
|
if (s && s.path) vvAiOpen(s.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wiring ──────────────────────────────────────────────────────────────
|
||||||
|
$('vv-ai-send').addEventListener('click', send);
|
||||||
|
$('vv-ai-input').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
|
||||||
|
});
|
||||||
|
$('vv-ai-clear').addEventListener('click', () => {
|
||||||
|
history = []; lastSources = [];
|
||||||
|
chat().innerHTML = '<div class="vv-ai-empty">Ask Varaverk about itself.<br>'
|
||||||
|
+ "Answers come only from this installation's own documentation, with sources.</div>";
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
|
||||||
|
|
||||||
|
loadBanner();
|
||||||
|
setInterval(loadBanner, 30000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user