Skip to content

[GUI] Web search and page reading for the web app's Chat (DuckDuckGo default, SearXNG, Brave, Tavily, Serper, Exa, Perplexity) - #3420

Open
c-OOL-io wants to merge 24 commits into
Niko1221:mainfrom
c-OOL-io:web-search
Open

c-OOL-io wants to merge 24 commits into
Niko1221:mainfrom
c-OOL-io:web-search

Conversation

@c-OOL-io

@c-OOL-io c-OOL-io commented Oct 20, 2026 •

Copy link
Copy Markdown

Problem and result

The Chat tab of the lean web app (the serve/Terminal frontend, not the App) has no web access at all: answers are limited to the model's weights, and agent-style use on a headless server had no source-backed option. Upstream shipped web search for the App in 0.2.0; the serve web chat route is still without it.

Before: no search in the web app's Chat, no sources. After: a built-in web search, modeled after how Open WebUI and similar chat tools do it: the model can look the question up before answering, and the answer shows its sources. Web search is completely off by default: until the user turns it on in Chat Settings, nothing about this feature sends anything out of the PC. Strata's Chat is deliberately lean, and this feature is built the same way: it adds no tokens until a chat turns search on, and results are budget-capped to a small slice of the model's context window (defaults scale with the context window), so the Chat gains recent information without losing what makes it comfortable for long sessions.

Related discussion

Replaces #2826: that PR was closed in the pre-0.2.0-beta tracker reset ("closed here does not mean rejected"); after the rebase its stored head commit became unreachable, so GitHub refuses to reopen it and this PR carries the same work on a live base. Upstream's App search (apps/host, DuckDuckGo via MCP) does not overlap with this work: this targets the serve web chat, with self-hostable engines (SearXNG) and server-side page reading.

Status: maintained & up to date

This branch is kept current with upstream 0.2.2 (engine 0.1.47) and re-tested after every refresh. On real hardware (Ryzen 9 7950X3D, RTX 4080 SUPER, Windows 11) the committed offline suite is green on the merged tree (27 tests: scheme guards, engine whitelist, reader tiers, keyed engines, context-aware budgets), and live DuckDuckGo and SearXNG searches still return results and sources unchanged. The fork web-search branch is pushed to the current 0.2.2 state, so this PR merges cleanly against latest main.

Approach

Search runs server-side in the serve backend (serve/websearch.py, stdlib-only); the browser frontend only configures and renders it. All providers are fetched through the Strata server (a browser could not call them directly because of CORS). API keys stay browser-only (localStorage), like the existing API-key field; nothing search-related is stored on the server.

What it looks like in the UI

  • A globe button in the composer (first icon, before the paperclip) toggles search for the next message.
  • Chat Settings (gear) has a Web search section: enable/disable, engine picker, SearXNG URL, API key field, max results, page reading and a page reader mode (see below). All web-search controls appear only when the search switch is on; switching it on expands the whole section and fires the privacy toast.
  • When search is on, the results are injected as context before the model's turn, with an instruction to answer from them and cite by [n].
  • Under each answer the sources appear as Sources (DuckDuckGo): [1] … | [2] … (clickable, engine named, read pages get a read badge), and they are included in the "Save this chat as Markdown" export.
  • While a search runs the status pill shows the truth instead of "Idle": a globe (instead of the state dot) with Framing query → Searching web + reading pages · DuckDuckGo · 12.4 s; a failed search reads Search failed for 2.5 s. The same status line appears in the answer bubble exactly where "Thinking…" would sit.
  • If the engine is busy with someone else's turn (another tab or agent) while this chat searches, the pill shows it as a hint (… · 1 queued) instead of hiding it or hijacking the label: one priority function owns the pill (search phase > engine state > queue count > idle), so the 1 s metrics poll and the 250 ms search timer can no longer overwrite each other.

Query optimizer

Conversational wording ("can you look up what the newest Qwen release from Alibaba is") makes weak search queries, so there is an "Optimize search query" toggle (on by default): one fast call to the user's own local model (temperature 0, reasoning off, max 100 tokens) extracts the core search question before the search runs.

  • The query stays in the language the user wrote in: English stays English, German stays German etc. Obvious typos are fixed.
  • If the call fails, is aborted, or returns something empty or rambling, the raw message is searched instead - the optimizer can never break a search.
  • Transparency: whenever what was searched differs from what was typed, the sources line shows Sources (DuckDuckGo) · searched: "…", and so does the Markdown export. An optimized query is never hidden behind a plain-looking result.

Reading result pages in full

Snippets alone often miss the details, so search can also fetch the top result pages and inject their extracted text (Open WebUI's "Web Loader" idea, stdlib html.parser based, which skips nav/footer/scripts, prefers main/article):

  • "Read result pages" toggle + "Pages to read" (1-5).
  • "Limit page text" toggle (on by default) with a "Max characters per page" field (default 5000). Turning it off sends whole pages; see the budget below for why that is still safe.

Page reader: direct fetch, or Jina Reader for JS-heavy pages

Plain urllib cannot render JavaScript-heavy pages, so there is a 3-tier Page reader setting:

  • Direct only (default): only the Strata server fetches pages; nothing goes to any third party.
  • Jina as fallback: direct first; pages that fail (JS walls, blocks) are retried through r.jina.ai (free hosted headless browser, no key needed, Apache-2.0).
  • Jina for all pages: every page goes through r.jina.ai.
  • The base URL is overridable via STRATA_JINA_URL (e.g. a self-hosted instance), and failures of either path degrade to a readable combined reason.
  • Privacy is surfaced, not buried: the dropdown explains per selection, a warning line appears whenever a third party could see the URLs, and turning search on fires a toast that names the engine and the reader mode ("… go through r.jina.ai - a third party sees those URLs").

Context-aware reading budget

How much search text goes into the prompt scales with the model's own context window (reported_ctx()), so small-context instances are never overloaded:

  • The reading budget is a slice of the window (~3 chars/token): 10 % normally, 25 % when the user explicitly turns the per-page limit off; the per-page cap is then derived from the budget instead of a fixed number.
  • First-use defaults scale the same way from /health's max_context (e.g. 16K ctx → 4 results / 1 page × 3000 chars; 128K → 6 / 2 × 5000; 262K → 8 / 3 × 5000).
  • Unknown context falls back to the plain 5000/page default.

Distinct, persistent search warnings

A failed search no longer fails silently: the reason stays on the message (in the sources line, warning-colored) and in the Markdown export, with a different message per cause:

  • no API key set → Brave Search needs its API key (Chat Settings > Web search) (same for Tavily/Serper/Exa/Perplexity; a rejected key gets its own message, incl. Brave's 422)
  • engine unreachable (e.g. SearXNG not running) → SearXNG at http://127.0.0.1:8888 is not reachable (…); start it or pick another engine
  • DuckDuckGo bot check → DDG's lite endpoint answers Chrome-impersonating UAs with its anomaly.js page; the fetcher uses an honest custom UA and, if the bot page still shows up, says so instead of reporting "no results"
  • engine reachable but nothing found → no results
  • unreadable page → per-result read_error (shown as a warning line), never a silent empty string

Security hardening

  • /search sits behind the web app's own-page guard (_own_page): a JSON body is required (415 for form posts) and a foreign Origin is rejected (403; trusted_origins still allowed), so a random web page cannot make this PC run searches or fetch URLs through it.
  • http(s) only: the SearXNG base URL and every result URL that gets page-read pass a scheme check (_http_url); file://, data:, javascript: and friends are refused with an explicit reason, so page reading can never become a local file reader.
  • Source links render defensively: only http(s) URLs become clickable <a href>; anything else shows as plain text, so no javascript:/data: links from a poisoned result.
  • Prompt-injection hardening: the injected search context states that titles, snippets and page text are untrusted web data whose embedded instructions must not be followed.
  • LAN-bound warning: /health reports lan_bound (server bound beyond loopback, e.g. 0.0.0.0); turning search on fires an extra toast line warning that queries and engine keys then cross the network in plain HTTP and that devices on it can use the search without an api_key. On the default 127.0.0.1 bind this warning never appears.
  • API keys stay browser-only (localStorage); nothing search-related is stored server-side.

Settings drawer updates

  • The drawer was titled "Sampling"; since it now also holds web search settings it is renamed to "Chat Settings" (gear tooltip: "Chat Settings: Thinking & Web Search"), the close button got a proper "Close" tooltip, and the save toast says "Settings saved".
  • The Reset button turns web search completely off and restores the first-use defaults: search disabled, engine back to DuckDuckGo, no API key, instead of leaving a stale engine or an active search behind.
  • The empty-state privacy note is qualified and split into two fixed lines: "Nothing leaves it. / Web search is off by default - when on, only the search query goes out."
  • Reset and Apply now sit fixed under the "Chat Settings" title while the options scroll below; the sampling options are grouped under a restored "Sampling" heading.

Scroll fix (drive-by)

Auto-follow-to-bottom was broken for turns where content grows past the stick threshold mid-stream (the thinking panel expanding could pin the view at the user's own message). Stickiness is now captured before the content grows, so the view keeps following the answer.

Engines

engine needs
DuckDuckGo (default, labeled "anonymous - no key needed") nothing (lite endpoint)
SearXNG a running instance (default http://127.0.0.1:8888)
Brave Search API key
Tavily API key
Serper (Google) API key
Exa API key
Perplexity (model sonar) API key

Exa and Perplexity call the same endpoints upstream's App calls (api.exa.ai/search with Bearer key, api.perplexity.ai/chat/completions model sonar, sources taken from search_results, falling back to bare citations when the plan omits them), so a key someone already has for the App works here unchanged.

Design notes

  • serve/websearch.py is stdlib-only (urllib, json, re, html.parser, os); no new dependencies, cross-platform (Windows/Linux/macOS).
  • POST /search carries its own engine config per request; nothing is stored on the server.
  • Page fetches are capped (10 s timeout, 2 MB download); read results use an explicit contract (content on success, read_error on failure, read_via for Jina-read pages) instead of heuristics.
  • /search has a catch-all so an unexpected error returns a clean 502 with a server-side log line instead of a broken connection.

Validation

Re-validated today (20.10.) on the rebased branch web-search @ 0042c1e2, which contains upstream main including 894d1be1 (0.2.1) and 8aaf188a (user-args/VRAM fix). Environment: Windows 11, RTX 4080 SUPER 16 GB, Ryzen 9 7950X3D, engine 0.1.45 (MIN_ENGINE (0,1,45) met), model qwen3.8-flash-next-iq3_xxs, 128K ctx.

Passed:

  • python -m unittest serve.test_websearch -v (the suite now committed in this branch): 27 tests, all green (scheme guard rejects 15 poison URL types file:// gopher:// javascript: data: ftp:// dict:// ldap:// sftp:// ssh:// mailto: telnet: jar: chrome-extension: vbscript: and accepts http(s); reader tier matrix direct/fallback/always incl. Jina rescue on a failing direct fetch and the combined both-paths-fail reason; bad reader values degrade to direct; non-HTML content refused; reading-budget clamps at 10 %/25 % of context with per-page caps derived at 4K/16K/128K; max_results clamp; keyed engines: Exa/Perplexity demand their key, Exa result+highlights mapping skips URL-less rows, Perplexity maps search_results and falls back to bare citations; hardening pass from an independent review pinned as tests: malformed engine envelopes (top-level lists, non-list result rows, non-dict rows, wrong-type highlights/citations) become named engine errors instead of crashes, junk rows dropped, snippet cap 2000 verified for every engine).
  • python -m py_compile serve/server.py serve/websearch.py serve/runconfig.py tools/strata_bench.py on the merged revision: clean.
  • Live serve run on the merged revision: engine starts with the config's user args passed through exactly (--vram-reserve-mib 700, --pcie-frac 0.20, --pool-workers 10; no override), and 3 benchmark arms measured agent decode 104.9 / 110.7 / 110.8 tok/s, chat decode median 94.1 vs 94.6 baseline from 19.10. (no regression introduced by the merge).
  • Interactive development tests on the pre-rebase revision (17.-19.10., live web app on Windows): DuckDuckGo and SearXNG return real results; each failure mode (missing key, rejected key, unreachable engine, DDG bot check, no results) shows its own warning on the message; sources appear in the answer and in the Markdown export; Reset restores DuckDuckGo defaults; the chat view follows long answers to the bottom. Page reading: real pages extract correctly; JS-heavy pages are rescued by the Jina tiers (fallback and always modes verified end-to-end, incl. both-paths-fail combined error); the optimizer's language preservation and fallback verified against the live model; origin guard 403/415 vs. legitimate 200 on the live server; lan_bound verified on loopback vs. 0.0.0.0 mock servers; the pill priority table unit-tested headlessly.
  • Manual re-check today (20.10., live web app after the hardening commit): searches via DuckDuckGo and SearXNG still return results and sources unchanged.

Failed: none observed.

Not run: CUDA/HIP/SYCL/Metal builds and the Docker images (this PR changes only the Python serve layer and its web frontend, no engine or C++ code); the 45 headless-JS UI checks from development were not re-run after the rebase (the committed Python suite covers the server side; the UI behaviors were verified interactively pre-rebase); Exa and Perplexity were not live-tested (no API keys available here; the request/response mapping is verified against mocks of the same endpoints upstream's App calls).

Limits

  • DuckDuckGo's lite endpoint is not an official API: if its bot-check page appears despite the honest UA, the search reports that honestly instead of "no results"; the engine picker is the fallback.
  • The Jina tiers send the page URLs to a third party when explicitly selected; Direct-only is the default and every selection shows the warning.
  • Search settings live in the browser (localStorage); a LAN-bound server warns that queries and keys then cross the network in plain HTTP.
  • The scroll fix is a drive-by UI fix unrelated to search.

Happy to adjust anything (naming, where the settings live, engine list, budget shares, the Jina default) if this should look different.

…uckGo default, SearXNG, Brave, Tavily, Serper), composer globe toggle, results injected as context, sources under each answer and in the Markdown export

- serve/websearch.py: stdlib-only engine wrappers; SearchError carries a readable message
- POST /search: every request carries its own engine config, nothing (no keys) is stored on the server
- keys stay in the browser's localStorage; a failed search leaves a persistent warning on the message
…ces, DDG bot-check fix, clearer errors, Settings wording and empty-state text
…too: reason' instead of two bare parenthesised reasons
… for engine base and page reading, links rendered only for http(s), search prompt marks web data untrusted, LAN-bound servers warn when search turns on
…d-as transparency in the sources line, Chat Settings drawer rework (fixed Reset/Apply, Sampling section, search controls collapse when off), live search progress in the pill and answer bubble with a single pill priority function so the metrics poll and the search timer can no longer fight
…s, optimizer, page reading, sources, hardening)
…telist, engine whitelist, reader tiers, reading budget)
@c-OOL-io c-OOL-io changed the title [GUI] Web search and page reading for the web app's Chat (DuckDuckGo default, SearXNG, Brave, Tavily, Serper) [GUI] Web search and page reading for the web app's Chat (DuckDuckGo default, SearXNG, Brave, Tavily, Serper, Exa, Perplexity) Oct 20, 2026
…losed picker shows only the default; the list described the section's offers), extended to all 7 engines
… snippet caps)

Independent dsh (DeepSeek harness) review findings pinned as tests:
- _result_rows: keyed engines reject malformed envelopes with a named
  SearchError instead of AttributeError; junk rows dropped
- highlights/citations type guards (list-only), Exa text fallback
- SNIPPET_CAP 2000 applied to every engine, not just Exa/Perplexity
- suite: 27 tests, all offline
@c-OOL-io

Copy link
Copy Markdown
Author

0042c1e2 is a robustness hardening pass driven by two independent review harnesses (every finding pinned as a test in the committed suite, 18 -> 27 tests):

  • malformed engine envelopes (top-level list, non-list rows array, non-dict rows, wrong-type highlights / citations) raise a named engine SearchError instead of an AttributeError inside the handler
  • junk rows are dropped, never fatal
  • Exa highlights is list-guarded (a wrong type must not fabricate a snippet char-by-char) and falls back to text
  • snippet cap 2000 applied uniformly to all 7 engines (was Exa/Perplexity only)

No behavior change for well-formed responses. Manual re-check of DuckDuckGo and SearXNG searches in the live web app today: results and sources unchanged.

@Niko1221

Copy link
Copy Markdown
Owner

Thank you for the very complete work on web search. Sending web searches out of the PC needs a privacy and safety design first, so this is planned for a later release, with no date yet. This will stay open until then.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants