diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index 7ae147e..576b11b 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -585,6 +585,7 @@ # 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_DEGOOG_URL="" # e.g. http://localhost:4444 — self-hosted, no key, /api/search 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 diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index e917759..62ccea5 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1837,13 +1837,15 @@ # 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 +# Provider: degoog | searxng | brave | tavily +# degoog self-hosted, no key — set HOST*_DEGOOG_URL. Aggregates several engines and returns +# them merged. The default, and the only one verified against a live service here # 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_PROVIDER=degoog AI_WEB_SEARCH_RESULTS=4 AI_WEB_SEARCH_TIMEOUT=6 diff --git a/Plugin/unraid/include/ai_web.php b/Plugin/unraid/include/ai_web.php index 0a51838..6200be0 100644 --- a/Plugin/unraid/include/ai_web.php +++ b/Plugin/unraid/include/ai_web.php @@ -5,11 +5,16 @@ // 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. +// One provider at a time, named by AI_WEB_SEARCH_PROVIDER. Four are supported because the right +// answer depends on what the operator already runs: degoog and SearXNG are self-hosted and need +// no key and no third party, while brave and tavily need 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. +// +// degoog is the default and the one verified here — it was already running on this host, and it +// aggregates several engines and returns them merged. DuckDuckGo is deliberately absent: both +// its scraping endpoints answer a challenge page from this network, and its keyless API returns +// nothing at all for a real question. // // DESIGN PRINCIPLES // Off by default, and not because it is dangerous. @@ -43,7 +48,7 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ require_once __DIR__ . '/ai.php'; -const VV_AI_WEB_PROVIDERS = ['searxng', 'brave', 'tavily']; +const VV_AI_WEB_PROVIDERS = ['degoog', '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 @@ -53,8 +58,8 @@ function vv_ai_web_enabled(): bool { } 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'; + $p = strtolower(trim((string)(vv_conf_vars()['AI_WEB_SEARCH_PROVIDER'] ?? 'degoog'))); + return in_array($p, VV_AI_WEB_PROVIDERS, true) ? $p : 'degoog'; } function vv_ai_web_results_max(): int { @@ -71,6 +76,15 @@ function vv_ai_web_searx_url(): string { return rtrim(trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_SEARXNG_URL'] ?? '')), '/'); } +function vv_ai_web_degoog_url(): string { + return rtrim(trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_DEGOOG_URL'] ?? '')), '/'); +} + +// Which providers need an address on this network rather than a key. Kept as a list because the +// readiness check and the error message both need the same answer and disagreeing about it is how +// a feature reports itself ready and then does nothing. +const VV_AI_WEB_SELF_HOSTED = ['degoog', 'searxng']; + function vv_ai_web_api_key(): string { return trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_WEB_SEARCH_API_KEY'] ?? '')); } @@ -81,12 +95,17 @@ 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']; + $p = vv_ai_web_provider(); + $host = strtoupper(vv_detect_host()); + + if ($p === 'degoog' && vv_ai_web_degoog_url() === '') { + return ['ok' => false, 'error' => $host . '_DEGOOG_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']; + if ($p === 'searxng' && vv_ai_web_searx_url() === '') { + return ['ok' => false, 'error' => $host . '_SEARXNG_URL is empty']; + } + if (!in_array($p, VV_AI_WEB_SELF_HOSTED, true) && vv_ai_web_api_key() === '') { + return ['ok' => false, 'error' => $host . '_WEB_SEARCH_API_KEY is empty']; } return ['ok' => true, 'provider' => $p]; } @@ -144,6 +163,25 @@ function vv_ai_web_normalise(array $rows): array { // 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(). +// Degoog — self-hosted, no key, and the only provider here whose response has been read from a +// live service rather than from documentation. It aggregates several engines and returns them +// already merged, so the ranking is its own. +// +// snippet before content: it fills both, and around one result in six has neither. Those are kept +// anyway — a title and a URL is still a citable source, and dropping them would silently shrink +// the result set the operator asked for. +function vv_ai_web_degoog(string $q): ?array { + $base = vv_ai_web_degoog_url(); + if ($base === '') return null; + $d = vv_ai_web_http($base . '/api/search?q=' . rawurlencode($q)); + if ($d === null) return null; + return array_map(fn($r) => ['title' => $r['title'] ?? '', + 'url' => $r['url'] ?? '', + 'snippet' => ($r['snippet'] ?? '') !== '' ? $r['snippet'] + : ($r['content'] ?? '')], + array_slice($d['results'] ?? [], 0, vv_ai_web_results_max())); +} + function vv_ai_web_searxng(string $q): ?array { $base = vv_ai_web_searx_url(); if ($base === '') return null; @@ -199,9 +237,10 @@ function vv_ai_web_search(string $question): array { $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), + 'brave' => vv_ai_web_brave($q), + 'tavily' => vv_ai_web_tavily($q), + 'searxng' => vv_ai_web_searxng($q), + default => vv_ai_web_degoog($q), }; if ($raw === null) {