Let General Chat search the web, and only General Chat

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.
This commit is contained in:
Gmer4Lfe
2026-08-09 22:42:43 -04:00
parent 47861b0dc3
commit 613634473a
10 changed files with 405 additions and 12 deletions
+7
View File
@@ -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 ━━━
+25
View File
@@ -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
+55 -7
View File
@@ -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";
}
}
+13
View File
@@ -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 ;;
+5 -3
View File
@@ -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.
+4 -1
View File
@@ -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 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
+30
View File
@@ -464,6 +464,16 @@ vv_ai_profiles_script();
if (!sources || !sources.length) return '';
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
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 += `<div class="vv-ai-src-i" data-web="${esc(s.url)}">`
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
+ `<span class="vv-ai-src-s">web</span></div>`;
return;
}
const label = [s.path, s.section, s.heading].filter(Boolean).join(' ');
h += `<div class="vv-ai-src-i" data-src="${esc(s.path)}">`
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
@@ -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) {
+12 -1
View File
@@ -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;
+227
View File
@@ -0,0 +1,227 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Web search for the chat profile. Turns a question into a handful of titled snippets with
// their URLs, for the worker to put in front of the model as context it can cite.
//
// OPERATIONAL MODEL
// One provider at a time, named by AI_WEB_SEARCH_PROVIDER. Three are supported because the
// right answer depends on what the operator is willing to run: a self-hosted SearXNG needs no
// key and no third party, and a hosted API needs no container. They differ only in how the
// request is shaped and where the fields sit in the reply, so everything after parsing is one
// code path.
//
// DESIGN PRINCIPLES
// Off by default, and not because it is dangerous.
// Searching sends the operator's question to something outside this house. That is a
// decision for the person whose question it is, so AI_WEB_SEARCH_ENABLED starts false and
// nothing turns it on.
//
// Chat only, and that is a capability, not a check in here.
// General chat holds no other capability precisely because it cannot write anything. Search
// is a read, so it is the one capability that fits there. The Varaverk assistant does not
// get it: its whole contract is that answers come from this installation's own documents,
// and a web result in that context is an answer that looks sourced and is not.
//
// Snippets, never pages.
// Only what the search API returns. Fetching the pages themselves would mean running an
// HTML parser over whatever the internet handed back, on a box that already has enough to
// do, for text the model has a 16k window to hold.
//
// OPERATIONAL SAFEGUARDS
// Bounded everywhere. A timeout on the call, a cap on the number of results, and a cap on the
// length of each snippet — the context this competes for is the same one retrieval needs.
//
// The query is the operator's question and nothing else. No conf values, no host names, no log
// contents are ever appended to it: this is the one path in the plugin that sends text off the
// machine, and what leaves has to be exactly what the operator typed.
//
// Redacted before it is logged, like every other question in this subsystem.
//
// DEPENDS ON
// include/ai.php vv_ai_redact(), vv_conf_vars()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/ai.php';
const VV_AI_WEB_PROVIDERS = ['searxng', 'brave', 'tavily'];
const VV_AI_WEB_MAX_QUERY = 300; // characters sent to the provider
const VV_AI_WEB_MAX_SNIPPET = 400; // characters kept from each result
function vv_ai_web_enabled(): bool {
if (!vv_ai_enabled()) return false;
return strtolower(trim((string)(vv_conf_vars()['AI_WEB_SEARCH_ENABLED'] ?? 'false'))) === 'true';
}
function vv_ai_web_provider(): string {
$p = strtolower(trim((string)(vv_conf_vars()['AI_WEB_SEARCH_PROVIDER'] ?? 'searxng')));
return in_array($p, VV_AI_WEB_PROVIDERS, true) ? $p : 'searxng';
}
function vv_ai_web_results_max(): int {
return max(1, min(10, (int)(vv_conf_vars()['AI_WEB_SEARCH_RESULTS'] ?? 4)));
}
function vv_ai_web_timeout(): int {
return max(2, min(30, (int)(vv_conf_vars()['AI_WEB_SEARCH_TIMEOUT'] ?? 6)));
}
// Per-host, because a SearXNG URL is an address on this network and an API key is a credential —
// both belong in host*.conf by the same rule that puts every other one there.
function vv_ai_web_searx_url(): string {
return rtrim(trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_SEARXNG_URL'] ?? '')), '/');
}
function vv_ai_web_api_key(): string {
return trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_WEB_SEARCH_API_KEY'] ?? ''));
}
// Is this provider actually set up? Answered separately from "is it switched on", because the
// two failures need different sentences: one is a setting, the other is a missing address or key.
function vv_ai_web_ready(): array {
if (!vv_ai_web_enabled()) {
return ['ok' => 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;
}
+27
View File
@@ -345,6 +345,20 @@ vv_ai_chat_markup('vv-ai', [
Varaverk Assistant only — the other profiles do not retrieve.</div>
</div>
</div>
<!-- Shown only for profiles that hold web_search, which today means General Chat alone.
Hidden rather than disabled for the others: a control that is visible and does nothing
reads as broken, and the reason it does nothing is a capability boundary that takes a
paragraph to explain. -->
<div class="vv-ai-set-r" id="vv-ai-web-row" style="display:none">
<span class="vv-ai-set-l">Web search</span>
<div>
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-web"> search the web for
this question</label>
<div class="vv-ai-set-d" id="vv-ai-web-state">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.</div>
</div>
</div>
<div class="vv-ai-set-r">
<span class="vv-ai-set-l">Reasoning</span>
<div>
@@ -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); },
});