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
Sources
';
sources.forEach((s, i) => {
+ // 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)}`
@@ -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.
+
+
+ Web search
+
+
+
General Chat only — it is the one profile
+ that cannot change anything, which is why it is the one allowed to look outside. Your
+ question is sent to the configured search provider.
+
+
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); },
});