From 613634473af277273bb579b2a9a89e1c603da526 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 22:42:43 -0400 Subject: [PATCH] Let General Chat search the web, and only General Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search is the one capability that fits the profile holding none: every other capability either reads this installation or changes it, and chat's whole contract is that it can do neither. The assistant deliberately does not get it — its contract is that answers come from this install's own documents, and a web result there is an answer that looks sourced and is not. A chat question about this machine hands off to the assistant before the search would run, so it never reaches the internet even with the box ticked. Off by default, and not because it is dangerous: searching sends the operator's question outside the house, which is theirs to decide. Asked for per turn as well as enabled in conf. Provider-agnostic, as asked — searxng, brave, tavily. Only whichever is configured here can be verified; all three read every field by name, so a shape that changes underneath yields no results rather than wrong ones. The explain fixtures asserted chat holds no capabilities at all, which is exactly the guarantee worth keeping. caps=only: now states the set rather than its emptiness, so the check still fails the day something else is granted there. --- Deployment/host.conf.template | 7 + Deployment/master.conf.template | 25 +++ Plugin/unraid/Tools/ai_chat_worker.php | 62 +++++- Plugin/unraid/Tools/ai_explain_check.sh | 13 ++ Plugin/unraid/Tools/ai_explain_fixtures.txt | 8 +- Plugin/unraid/api/ai.php | 5 +- Plugin/unraid/include/ai_chat.php | 30 +++ Plugin/unraid/include/ai_profiles.php | 13 +- Plugin/unraid/include/ai_web.php | 227 ++++++++++++++++++++ Plugin/unraid/pages/ai.php | 27 +++ 10 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 Plugin/unraid/include/ai_web.php diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index 8c13a61..7ae147e 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -580,6 +580,13 @@ HOSTN_OLLAMA_URL="" # e.g. http://localhost:11434 — empty if no local Ollama HOSTN_OLLAMA_GPU_UUID="" # pins Ollama to one card on multi-GPU hosts HOSTN_OLLAMA_MODEL="hf.co/unsloth/Qwen3-14B-GGUF:IQ4_XS" # generation — must fully offload; see README-AI.md + +# ━━━ Web search ━━━ +# Per-host because one is an address on this network and the other is a credential. Only the +# General Chat profile can use these — it is the profile that cannot change anything, which is +# why it is the one allowed to look outside. Off until AI_WEB_SEARCH_ENABLED says otherwise. + HOSTN_SEARXNG_URL="" # e.g. http://localhost:8888 — needs format: [json] in its settings.yml + HOSTN_WEB_SEARCH_API_KEY="" # brave or tavily; unused when the provider is searxng HOSTN_OLLAMA_EMBED_MODEL="nomic-embed-text" # embeddings — the generation model cannot embed # ━━━ Authelia ━━━ diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index ca5f14a..e917759 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1822,6 +1822,31 @@ # Both are bounded by time (since the last pass) and by a line cap, so a flood costs one pass. # Tools/ai_log_check.sh replays this host's real logs against the patterns — run it after # changing any of them. +# ━━━ AI Web Search ━━━ +# General Chat only. Every other profile either reads this installation or changes it; chat holds +# no capability at all, and that is exactly why searching is the one thing it may do — a read that +# leaves the house is safe on the profile that cannot act on what it finds. The Varaverk assistant +# deliberately does not get it: its contract is that answers come from this installation's own +# documents, and a web result there is an answer that looks sourced and is not. +# +# Off by default, and not because it is dangerous. Searching sends the operator's question to +# something outside this house, and that is their decision to make rather than a default to +# inherit. Nothing turns it on. +# +# Asked for per turn as well — there is a checkbox on the AI tab, and a question is only searched +# when it is ticked. A question about this machine hands off to the assistant before the search +# would run, so it never reaches the internet even with the box ticked. +# +# Provider: searxng | brave | tavily +# searxng self-hosted, no key, no third party — set HOST*_SEARXNG_URL, and enable format: [json] +# in its own settings.yml, which the default image ships with off +# brave HOST*_WEB_SEARCH_API_KEY, free tier available +# tavily HOST*_WEB_SEARCH_API_KEY + AI_WEB_SEARCH_ENABLED=false + AI_WEB_SEARCH_PROVIDER=searxng + AI_WEB_SEARCH_RESULTS=4 + AI_WEB_SEARCH_TIMEOUT=6 + AI_REPAIR_SYSLOG_ENABLED=true AI_REPAIR_SYSLOG_MAX_LINES=4000 AI_REPAIR_CONTAINER_LOGS_ENABLED=true diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 630e374..0a657a8 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -121,8 +121,8 @@ if ($explain) { exit(2); } } else { - [$jobFile, $question, $historyJson, $kind, $think, $profile, $scope] = - array_slice($argv, 1, 7) + array_fill(0, 7, ''); + [$jobFile, $question, $historyJson, $kind, $think, $profile, $scope, $webArg] = + array_slice($argv, 1, 8) + array_fill(0, 8, ''); if ($jobFile === '' || $question === '') exit(1); if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); @@ -197,6 +197,49 @@ $sources = []; $context = ''; $tRetrieve = 0.0; +// ── Web search ─────────────────────────────────────────────────────────────────────────────── +// Asked for per turn, never decided here. The operator ticks it, and it only exists on the one +// profile that holds the capability — which is General Chat, and only because chat cannot write. +// +// Placed ahead of retrieval so its results are numbered first and the citation numbers the model +// sees match the order the page lists them in. It is mutually exclusive with retrieval in +// practice rather than by rule: no profile holds both, because the assistant's contract is that +// its answers come from this installation's own documents. +// +// A handoff has already happened by this point if it was going to — a chat question about this +// machine has become a varaverk one, which does not hold web_search, so asking about Varaverk +// never reaches the internet even with the box ticked. +$webAsked = ($webArg ?? '') === '1'; +if ($webAsked && $can('web_search')) { + require_once dirname(__DIR__) . '/include/ai_web.php'; + $tw = microtime(true); + $web = vv_ai_web_search($question); + wlog(sprintf('web search provider=%s ok=%s results=%d %s(%dms)', + $web['provider'] ?? '?', ($web['ok'] ?? false) ? 'yes' : 'no', + count($web['results'] ?? []), isset($web['error']) ? '(' . $web['error'] . ') ' : '', + (int)((microtime(true) - $tw) * 1000))); + + if (($web['ok'] ?? false) && $web['results']) { + $context .= vv_ai_web_context($web['results'], 0); + foreach ($web['results'] as $r) { + // path carries the URL so the existing citation wiring keeps working unchanged; url + // is what tells the page to open a browser tab instead of the source viewer. + $sources[] = ['path' => $r['url'], 'url' => $r['url'], 'section' => '', + 'heading' => $r['title'], 'score' => 0, 'web' => true]; + } + $attached['web_search'] = count($web['results']) . ' results'; + } elseif (!($web['ok'] ?? false)) { + // Told to the model rather than swallowed. An assistant that searched and got nothing + // must not answer as though it had searched and found nothing exists. + $context .= "A web search was requested but did not run: " . ($web['error'] ?? 'unknown') + . ". Say so rather than answering as though the web had been consulted.\n\n"; + $attached['web_search'] = 'failed: ' . ($web['error'] ?? 'unknown'); + } else { + $context .= "A web search was run and returned no results. Say so.\n\n"; + $attached['web_search'] = 'no results'; + } +} + if ($can('retrieve')) { jw($jobFile, ['status' => 'retrieving']); @@ -233,14 +276,19 @@ if ($can('retrieve')) { $tRetrieve = microtime(true) - $t0; - $sources = array_map(fn($x) => [ - 'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '', - 'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0, - ], $r['results']); + // Appended, and numbered from whatever is already there. No profile holds both web_search and + // retrieve, so today this offset is always zero — but assigning over $sources and numbering + // from one would silently drop the other set the moment one ever does, and a citation + // pointing at the wrong source is worse than no citation. + $offset = count($sources); + foreach ($r['results'] as $x) { + $sources[] = ['path' => $x['path'] ?? '', 'section' => $x['section'] ?? '', + 'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0]; + } 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"; + $context .= '[' . ($offset + $i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n"; } } diff --git a/Plugin/unraid/Tools/ai_explain_check.sh b/Plugin/unraid/Tools/ai_explain_check.sh index 333a3f9..d826d20 100755 --- a/Plugin/unraid/Tools/ai_explain_check.sh +++ b/Plugin/unraid/Tools/ai_explain_check.sh @@ -117,8 +117,21 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do exp=$([[ "$want" == "yes" ]] && echo YES || echo no) [[ "$got_diag" == "$exp" ]] || problems+=("diagnostic: want $exp, got $got_diag") ;; caps) + # none holds nothing at all + # only:a,b holds exactly these and nothing else + # a holds at least this + # + # only: exists because General Chat's guarantee is not "it can search" but "search + # is the only thing it can do". A presence check would still pass on the day + # something else is granted there, which is the day the check was written for. if [[ "$want" == "none" ]]; then [[ "$got_caps" == "(none"* ]] || problems+=("caps: want none, got $got_caps") + elif [[ "$want" == only:* ]]; then + want_set="${want#only:}" + got_set="$(printf '%s' "$got_caps" | tr -d ' ' | tr ',' '\n' | sort | paste -sd, -)" + exp_set="$(printf '%s' "$want_set" | tr -d ' ' | tr ',' '\n' | sort | paste -sd, -)" + [[ "$got_set" == "$exp_set" ]] \ + || problems+=("caps: want exactly [$exp_set], got [$got_set]") else [[ "$got_caps" == *"$want"* ]] || problems+=("caps: want $want in [$got_caps]") fi ;; diff --git a/Plugin/unraid/Tools/ai_explain_fixtures.txt b/Plugin/unraid/Tools/ai_explain_fixtures.txt index 36a83af..eb1b4ab 100644 --- a/Plugin/unraid/Tools/ai_explain_fixtures.txt +++ b/Plugin/unraid/Tools/ai_explain_fixtures.txt @@ -19,6 +19,8 @@ # run=yes|no the run-outcome gate # diag=yes|no the diagnostic gate # caps=none the profile holds no capabilities at all +# caps=only:a,b it holds exactly these and nothing else +# caps=a it holds at least this # has=a,b every one of these must be attached # hasnt=a,b none of these may be attached # @@ -70,7 +72,7 @@ Why did this run fail? | troubleshoot | Orchestrators/daily_sync_maintenance | | How did this run go? | troubleshoot | Orchestrators/weekly_sync_maintenance | | has=log_tail,run_record # ── General Chat holds nothing, and hands Varaverk questions up rather than deferring ────────── -how was your day | chat | | | profile=chat caps=none hasnt=health,log_tail,incidents,conf_keys +how was your day | chat | | | profile=chat caps=only:web_search hasnt=health,log_tail,incidents,conf_keys what does arr_sync.sh do | chat | | | profile=varaverk is RSYNC_ENABLED on right now | chat | | | profile=varaverk has=conf_keys how did the daily orch go | chat | | | profile=varaverk run=yes has=run_record @@ -99,8 +101,8 @@ write me a bash script for backups | chat | | | profile=code # Diagnostic phrasing about nothing here stays in chat — "why" is not a Varaverk question on its # own, and escalating adds capability, so a wrong escalation costs more than a missed one. -why is the sky blue | chat | | | profile=chat caps=none hasnt=health,log_tail,incidents,conf_keys -what is wrong with my car | chat | | | profile=chat caps=none +why is the sky blue | chat | | | profile=chat caps=only:web_search hasnt=health,log_tail,incidents,conf_keys +what is wrong with my car | chat | | | profile=chat caps=only:web_search # Naming a script and asking what it does is documentation, not a request to write one. The code # router is anchored on the verb for exactly this pair. diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 302eb16..f8f0471 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -440,7 +440,10 @@ if ($action === 'ask') { . escapeshellarg($kind) . ' ' . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' ' . escapeshellarg($profile) . ' ' - . escapeshellarg($scope) + . escapeshellarg($scope) . ' ' + // Asked for per turn. Only meaningful on a profile holding web_search — the worker + // checks that, so a crafted web=1 against any other profile changes nothing. + . escapeshellarg(($_POST['web'] ?? '') === '1' ? '1' : '0') . ' >/dev/null 2>&1 { + // A web result is a page on the internet, not a file in this install: it opens in a tab + // rather than in the source viewer, it leads with its title rather than its URL, and it + // carries no retrieval score because nothing here scored it. + if (s.web && s.url) { + const label = [s.heading, s.url].filter(Boolean).join(' — '); + h += `
` + + `[${i+1}]${esc(label)}` + + `web
`; + return; + } const label = [s.path, s.section, s.heading].filter(Boolean).join(' › '); h += `
` + `[${i+1}]${esc(label)}` @@ -511,10 +521,23 @@ vv_ai_profiles_script(); const think = e.target.closest('.vv-ai-think-t'); if (think) { think.nextElementSibling.classList.toggle('open'); return; } const src = e.target.closest('.vv-ai-src-i'); + // Through vvSafeUrl, which is the global that exists precisely so a URL from outside this + // machine cannot become a javascript: href. The server drops anything that is not http(s) + // as well; this is the second of the two, not the only one. + if (src && src.dataset.web) { + const u = vvSafeUrl(src.dataset.web); + if (u) window.open(u, '_blank', 'noopener,noreferrer'); + return; + } if (src && src.dataset.src) { vvAiOpen(src.dataset.src); return; } const cite = e.target.closest('.vv-ai-cite'); if (cite) { const s = lastSources[Number(cite.dataset.cite) - 1]; + if (s && s.web && s.url) { + const u = vvSafeUrl(s.url); + if (u) window.open(u, '_blank', 'noopener,noreferrer'); + return; + } if (s && s.path) vvAiOpen(s.path); return; } @@ -556,6 +579,7 @@ vv_ai_profiles_script(); // never reaches PHP — no CSRF termination, no fatal, no entry log. const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null; const thinkEl = o.thinkEl ? document.getElementById(o.thinkEl) : null; + const webEl = o.webEl ? document.getElementById(o.webEl) : null; let res; try { @@ -571,6 +595,12 @@ vv_ai_profiles_script(); // not — an inline answer that stalls reads as broken. think: (typeof o.think === 'function' ? o.think(profile) : thinkEl ? thinkEl.checked : true) ? '1' : '0', + // Only sent when the profile in force actually holds the capability, so ticking the box + // and then switching to the assistant cannot send a question about this machine to a + // search engine. The worker checks the same thing again — this is the courtesy, not + // the control. + web: (PROFILES[profile] && PROFILES[profile].web && webEl && webEl.checked) + ? '1' : '0', }); res = fetch(API, { method: 'POST', headers: POST_HEAD, body }); } catch (e) { diff --git a/Plugin/unraid/include/ai_profiles.php b/Plugin/unraid/include/ai_profiles.php index c6b6560..b92dae5 100644 --- a/Plugin/unraid/include/ai_profiles.php +++ b/Plugin/unraid/include/ai_profiles.php @@ -84,7 +84,13 @@ const VV_AI_PROFILES_DEF = [ 'hint' => 'Ordinary conversation. Hands anything about this install to the assistant on its own.', 'turns' => 8, 'ui' => true, - 'caps' => [], + // The only capability general chat holds, and it holds it *because* chat cannot write. + // Search is a read that leaves the house; every other capability in this table either + // reads this installation or changes it, and neither belongs on the profile whose + // contract is that it has no reach into the machine at all. The Varaverk assistant is + // deliberately not given it: its contract is that answers come from this installation's + // own documents, and a web result there is an answer that looks sourced and is not. + 'caps' => ['web_search'], ], 'code' => [ 'label' => 'Code Sketcher', @@ -148,6 +154,7 @@ const VV_AI_CAP_MEANING = [ 'file_findings'=> 'may record, acknowledge and close findings about this installation', 'phrasebook' => 'what the operator calls things, and what they have corrected before', 'past_fixes' => 'findings already closed — what fixed this last time', + 'web_search' => 'may search the web — the one capability that sends text off this machine', ]; function vv_ai_profiles(): array { return VV_AI_PROFILES_DEF; } @@ -204,6 +211,10 @@ function vv_ai_profiles_client(): array { 'turns' => $p['turns'], 'ui' => (bool)$p['ui'], 'kind' => in_array('kind_filter', $p['caps'], true), + // Whether to offer the search box, on the same terms as the kind filter: derived from + // the capability rather than named per profile, so granting or removing it in the + // table above is the only edit either side needs. + 'web' => in_array('web_search', $p['caps'], true), ]; } return $out; diff --git a/Plugin/unraid/include/ai_web.php b/Plugin/unraid/include/ai_web.php new file mode 100644 index 0000000..0a51838 --- /dev/null +++ b/Plugin/unraid/include/ai_web.php @@ -0,0 +1,227 @@ + false, 'error' => 'AI_WEB_SEARCH_ENABLED is false']; + } + $p = vv_ai_web_provider(); + if ($p === 'searxng' && vv_ai_web_searx_url() === '') { + return ['ok' => false, 'error' => strtoupper(vv_detect_host()) . '_SEARXNG_URL is empty']; + } + if ($p !== 'searxng' && vv_ai_web_api_key() === '') { + return ['ok' => false, 'error' => strtoupper(vv_detect_host()) . '_WEB_SEARCH_API_KEY is empty']; + } + return ['ok' => true, 'provider' => $p]; +} + +// One HTTP call, returning the decoded body or null. curl rather than file_get_contents so the +// timeout is real and a proxy-less environment does not hang the worker. +function vv_ai_web_http(string $url, array $headers = [], ?array $postJson = null): ?array { + $ch = curl_init($url); + if ($ch === false) return null; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => vv_ai_web_timeout(), + CURLOPT_CONNECTTIMEOUT => min(5, vv_ai_web_timeout()), + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), + CURLOPT_USERAGENT => 'Varaverk', + ]); + if ($postJson !== null) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postJson)); + curl_setopt($ch, CURLOPT_HTTPHEADER, + array_merge(['Accept: application/json', 'Content-Type: application/json'], $headers)); + } + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false || $code < 200 || $code >= 300) return null; + $d = json_decode((string)$body, true); + return is_array($d) ? $d : null; +} + +// Everything below reduces to the same three fields, so the worker never learns which provider +// answered: title, url, snippet. +function vv_ai_web_normalise(array $rows): array { + $out = []; + foreach ($rows as $r) { + $url = trim((string)($r['url'] ?? '')); + // Only http(s). A provider returning anything else is either broken or hostile, and this + // string ends up in an href. + if (!preg_match('#^https?://#i', $url)) continue; + $title = trim((string)($r['title'] ?? '')); + $snip = trim(preg_replace('/\s+/u', ' ', (string)($r['snippet'] ?? ''))); + if ($title === '' && $snip === '') continue; + $out[] = ['title' => mb_substr($title !== '' ? $title : $url, 0, 160), + 'url' => $url, + 'snippet' => mb_substr($snip, 0, VV_AI_WEB_MAX_SNIPPET)]; + } + return $out; +} + +// ── Providers ──────────────────────────────────────────────────────────────────────────────── +// Each returns raw {title,url,snippet} rows or null when the call failed. Only the one this +// installation is configured for has ever been exercised against a live service; the other two +// are written from their published response shapes and are unverified here. Whichever is in use, +// a shape that changes underneath produces zero results rather than wrong ones, because every +// field is read by name and anything missing is dropped by vv_ai_web_normalise(). + +function vv_ai_web_searxng(string $q): ?array { + $base = vv_ai_web_searx_url(); + if ($base === '') return null; + // format=json must be enabled in SearXNG's own settings.yml; it is off in the default image. + $url = $base . '/search?format=json&safesearch=0&q=' . rawurlencode($q); + $d = vv_ai_web_http($url); + if ($d === null) return null; + return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '', + 'snippet' => $r['content'] ?? ''], + array_slice($d['results'] ?? [], 0, vv_ai_web_results_max())); +} + +function vv_ai_web_brave(string $q): ?array { + $key = vv_ai_web_api_key(); + if ($key === '') return null; + $url = 'https://api.search.brave.com/res/v1/web/search?count=' . vv_ai_web_results_max() + . '&q=' . rawurlencode($q); + $d = vv_ai_web_http($url, ['X-Subscription-Token: ' . $key]); + if ($d === null) return null; + return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '', + 'snippet' => $r['description'] ?? ''], + array_slice($d['web']['results'] ?? [], 0, vv_ai_web_results_max())); +} + +function vv_ai_web_tavily(string $q): ?array { + $key = vv_ai_web_api_key(); + if ($key === '') return null; + $d = vv_ai_web_http('https://api.tavily.com/search', [], + ['api_key' => $key, 'query' => $q, 'max_results' => vv_ai_web_results_max(), + 'search_depth' => 'basic']); + if ($d === null) return null; + return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '', + 'snippet' => $r['content'] ?? ''], + array_slice($d['results'] ?? [], 0, vv_ai_web_results_max())); +} + +// The one function the worker calls. +// +// Returns ['ok'=>bool, 'results'=>[…], 'provider'=>…, 'error'=>…]. An empty result set is not an +// error: "the web had nothing" and "the search did not happen" are different things to tell the +// model, and collapsing them is how an answer ends up asserting that nothing exists. +function vv_ai_web_search(string $question): array { + $ready = vv_ai_web_ready(); + if (!($ready['ok'] ?? false)) { + return ['ok' => false, 'results' => [], 'provider' => vv_ai_web_provider(), + 'error' => $ready['error']]; + } + + $q = trim(preg_replace('/\s+/u', ' ', $question)); + if ($q === '') return ['ok' => false, 'results' => [], 'provider' => vv_ai_web_provider(), + 'error' => 'empty query']; + $q = mb_substr($q, 0, VV_AI_WEB_MAX_QUERY); + + $provider = vv_ai_web_provider(); + $raw = match ($provider) { + 'brave' => vv_ai_web_brave($q), + 'tavily' => vv_ai_web_tavily($q), + default => vv_ai_web_searxng($q), + }; + + if ($raw === null) { + return ['ok' => false, 'results' => [], 'provider' => $provider, + 'error' => $provider . ' did not answer']; + } + return ['ok' => true, 'provider' => $provider, + 'results' => array_slice(vv_ai_web_normalise($raw), 0, vv_ai_web_results_max())]; +} + +// The block that goes in front of the model, and the sources the page renders beside the answer. +// Numbered from an offset so web results can sit after retrieved passages without either set +// renumbering the other. +function vv_ai_web_context(array $results, int $offset = 0): string { + if (!$results) return ''; + $s = "Web search results. These are from outside this installation — cite them by number, and\n" + . "say when something is a general answer rather than one about this machine.\n\n"; + foreach ($results as $i => $r) { + $s .= '[' . ($offset + $i + 1) . '] ' . $r['title'] . ' — ' . $r['url'] . "\n" + . $r['snippet'] . "\n\n"; + } + return $s; +} diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 01e1be2..f391bdf 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -345,6 +345,20 @@ vv_ai_chat_markup('vv-ai', [ Varaverk Assistant only — the other profiles do not retrieve.
+ +
Reasoning
@@ -843,14 +857,25 @@ vv_ai_chat_markup('vv-ai', [ const kind = $('vv-ai-kind'); const bits = [kind.options[kind.selectedIndex].text.replace(/ —.*$/, '')]; if (!$('vv-ai-think').checked) bits.push('no reasoning'); + if ($('vv-ai-web').checked && $('vv-ai-web-row').style.display !== 'none') bits.push('web search'); $('vv-ai-set-sum').textContent = bits.join(' · '); } + + // The row follows the profile, off the same capability the server publishes. Chat is not the + // profile you land on, so this starts hidden and appears when you switch to it. + function webRowFor(profile) { + const p = (window.VvAiProfiles || {})[profile]; + const row = $('vv-ai-web-row'); + row.style.display = (p && p.web) ? '' : 'none'; + setSummary(); + } $('vv-ai-set-t').addEventListener('click', () => { $('vv-ai-set-t').classList.toggle('open'); $('vv-ai-set-b').classList.toggle('open'); }); $('vv-ai-kind').addEventListener('change', setSummary); $('vv-ai-think').addEventListener('change', setSummary); + $('vv-ai-web').addEventListener('change', setSummary); setSummary(); $('vv-ai-tok-hosts').addEventListener('click', e => { @@ -913,9 +938,11 @@ vv_ai_chat_markup('vv-ai', [ profile: 'varaverk', // the strict profile is the one you land on kindEl: 'vv-ai-kind', thinkEl: 'vv-ai-think', + webEl: 'vv-ai-web', empty: "Ask Varaverk about itself. Answers come only from this installation's own " + 'documentation, with sources.', onTurn: () => { loadBanner(true); loadTokens(); }, + onProfile: p => webRowFor(p), onChats: id => { if (chatList) chatList.setActive(id); }, });