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); }, });