16 Commits

Author SHA1 Message Date
jasonwitty 1fa83635c0 [fix] plugin: accept version pinned model names
The pattern that validates the model name rejected "@", so names like
gemini-1.5-pro@001 were refused with "no valid model configured".  Those
names are ordinary on the gateways this plugin can be pointed at, which
made the api_key support less useful than it looks.

Verified against a LiteLLM gateway serving an alias named gemma4-e4b@001:
the model list, the request and the streamed answer all carry the pinned
name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:00:13 -07:00
jasonwitty 37d7d11959 [doc] ai_summary: describe the plugin, not the reasoning behind it
Both pages carried design rationale and implementation notes that belong
in a review conversation rather than in documentation: why NDJSON is
sent to the browser instead of SSE, why the endpoint is registered in
webapp.py, why the outgoing proxy is bypassed. They are gone, along with
the troubleshooting section.

The section about public instances no longer opens by saying the plugin
is meant for private instances, which contradicted the instructions that
followed it. It now explains why the server, model and key are user
preferences at all -- so that they can be changed at home without
editing settings.yml -- and what that means once other people can reach
the instance.

"AI Summary" is the plugin, "AI summary" is the text it produces, and an
"instance" is always a SearXNG one; a page that also talks about LLM
servers cannot leave that to context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:11:49 -07:00
jasonwitty 054b3a5c0d [fix] simple theme: show why the AI summary failed
The error box said only "Error loading the AI summary".  The status of
the failed request was raised and then dropped, so a user could not tell
an LLM server that is unreachable (502) from a plugin that is switched
off (403) or a request that was rejected (400).

The reason is now appended to the message.  On a phone or in a webview,
where dev tools are not an option, it is the only thing the person
looking at the failure has to go on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:17:09 -07:00
jasonwitty 0cc3748866 [fix] plugin: repeat the request on 5xx as well
A server that is not ready does not always take its time about saying so.
Ollama answers 5xx immediately while a model is still being loaded, and a
gateway with no upstream yet does the same, so the failure arrives at
once rather than as a timeout.

Both mean "not right now" and are worth one more attempt.  4xx keeps
being final -- a wrong API key or an unknown model name is not going to
change between two requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:58:31 -07:00
jasonwitty 4a582c0a15 [fix] plugin: ask the LLM server twice before giving up
An idle LLM server unloads the model and loads it again on the next
request.  Nothing of the response is sent while that load runs, so a
request that arrives on a cold server can exceed read_timeout and fail --
reliably making the first search after an idle period the one that does
not get a summary.  That same request is what starts the load, so asking
again succeeds.

Only a server that failed to answer is asked again.  A server that did
answer with an error status is not: a wrong API key or an unknown model
name does not become right on a second attempt.

The status and body of such an error response are now logged.  Until now
every upstream failure looked identical from the outside -- HTTP 502 with
no indication of whether the key, the model name or the network was at
fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:37:06 -07:00
jasonwitty ce02bebf7b [doc] ai_summary: say what the summary is for in the introduction
The introduction described the mechanism before the purpose.  It now
states what the plugin does, that it is meant for a local LLM server
speaking the OpenAI chat completions API, and -- the part that was
missing -- that the summary exists to condense the results the query
actually returned rather than to recite training data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:31:01 -07:00
jasonwitty 6b50b23467 [fix] doc: drop the contents:: directive, name the plugin consistently
Furo renders its own page-local table of contents and reports an error
when a page adds one with contents::.  No other page in the tree uses
that directive; these two now follow suit.

The plugin is named "AI Summary" where the name of the plugin is meant,
and "AI summary" where the generated text itself is meant.  That also
fixes the entry in the plugin navigation, which takes its label from the
page title.

Two references to the configuration page rendered as its title, the bare
YAML key "ai_summary:", which reads as a typo in a sentence.  They now
carry explicit link text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:17:53 -07:00
jasonwitty b6eed9a993 [doc] ai_summary: rewrite for readers new to the feature
The documentation was written from the perspective of someone who had
just implemented the plugin: it opened with SSRF caveats and settings
keys, and explained decisions rather than usage.

The admin page now starts from what the feature is, followed by a
four-step quickstart (install Ollama, pull a model, configure, restart)
and a troubleshooting section for the failures that actually occur.  The
quickstart repeats the default plugins because a plugins: block replaces
that list instead of merging into it -- following the short version of
the instructions would otherwise switch every other plugin off.

The developer page gains a rendered data flow diagram.  The two-request
design -- placeholder first, streamed answer second -- is the part of
this plugin that is hard to convey in prose, and the SSE to NDJSON
change is easier to see than to read about.

The module docstring now describes the module and links to both pages,
instead of restating administration guidance.

Add Jason Witty to AUTHORS.rst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:30:53 -07:00
jasonwitty 8ff85790cf [doc] ai_summary: document what grounding sends to the LLM server
Grounding is on by default, and it sends the top search results along
with the query.  Which data leaves the instance depends on where the LLM
server runs -- per the privacy-by-design directive the reader has to be
able to see that from the documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:10:24 -07:00
jasonwitty c7e8bba488 [fix] simple theme: style password inputs like text inputs
The AI summary API key is a password input, the preferences stylesheet
only styled input[type="text"] -- the field rendered with the browser
default (white) instead of the theme background.

The description of the field is shortened to what a user needs to decide
whether to fill it in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:24:45 -07:00
jasonwitty 8edc368752 [mod] plugin: AI tab only when activated, user API key, grounding on
Three changes to the ai_summary plugin:

- The *AI Summary* preferences tab is only rendered when the plugin is
  activated in settings.yml.  An instance that does not offer AI
  summaries no longer shows an AI tab at all.  The gate is the
  administrator setting, not the user opt-out, because the per user
  on/off switch lives inside that tab -- hiding it on opt-out would
  leave no way to opt back in.

- Users can configure an API key for their own LLM server
  (ai_summary_api_key).  The administrator key is still only sent to
  base_url and the user key only to a server the user configured, so
  neither key can be captured through the other.  The setting is marked
  secret: credentials are excluded from the preferences URL, which users
  copy around to transfer or share their preferences.

- Grounding summaries on the search results is now the default; the
  extra cost of the longer prompt is moderate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:45:50 -07:00
jasonwitty ce400f993c [fix] plugin: ignore credentials in a user configured LLM server URL
httpx derives an "Authorization: Basic" header from the userinfo of a
URL, so a user could make SearXNG send a header of their choosing to a
host of their choosing (e.g. to probe an internal service behind basic
auth).  A user preference carrying credentials is now ignored and the
administrator default is used instead; credentials in the configured
base_url are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:05:24 -07:00
jasonwitty 19cc7a6f9f [feat] plugin: optional API key for the AI summary LLM server
Servers that require authentication (e.g. vLLM or llama.cpp started with
--api-key, or an LLM server behind an authenticating reverse proxy) can
now be configured with an ai_summary.api_key, sent as "Authorization:
Bearer".

The key is administrator configuration only: there is no preference for
it, and it is only sent to the configured base_url.  Users can point the
ai_summary_server preference at a server of their own, and such a server
must not be handed the instance API key -- otherwise every user of the
instance could capture it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:04:17 -07:00
jasonwitty 4abb7dba67 [mod] ai_summary plugin: switch to the OpenAI chat completions API
Talk to the LLM server via GET /v1/models and POST /v1/chat/completions
(SSE) instead of Ollama's native API.  Any OpenAI compatible server now
works (Ollama, vLLM, llama.cpp, LM Studio, Hugging Face TGI, ...);
Ollama serves this API natively, existing setups keep working unchanged.

The Ollama specific keep_alive option is dropped, the ai_summary.grounding
setting is added as instance wide default of the grounding preference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:01:24 -07:00
jasonwitty 112541db28 [build] static: rebuild simple theme (ai_summary plugin)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:47:18 -07:00
jasonwitty b47fd08cb7 [feat] plugin: AI summary of search results via local Ollama server
Add an optional, disabled-by-default plugin that shows an AI generated
summary at the top of the result page, generated by a (local) Ollama
server:

- async: the result page is never delayed; a client plugin streams the
  answer (NDJSON over a new /ai_summary endpoint) into a placeholder
  answer with a typing indicator, collapsed behind a More button, with
  an inline follow-up chat
- trigger: first page of general searches only, skipped when an infobox
  or instant answer already answers the query
- grounding (per-user preference): send the top result snippets as
  context, the model answers from them instead of its own knowledge
- configuration: new AI Summary preferences tab (server URL, model,
  grounding) with instance defaults in a new ai_summary: settings
  section; all three preferences can be locked for public instances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:47:18 -07:00
32 changed files with 1918 additions and 13 deletions
+1
View File
@@ -179,3 +179,4 @@ features or generally made SearXNG better:
- Tommaso Colella `<https://github.com/gioleppe>` - Tommaso Colella `<https://github.com/gioleppe>`
- @AgentScrubbles - @AgentScrubbles
- Filip Mikina `<https://github.com/fiffek>` - Filip Mikina `<https://github.com/fiffek>`
- Jason Witty `<https://github.com/jasonwitty>`
+223
View File
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Plugin } from "../Plugin.ts";
import { settings } from "../toolkit.ts";
import { assertElement } from "../util/assertElement.ts";
type Message = { role: "user" | "assistant"; content: string };
type ContextItem = { title: string; url: string; snippet: string };
type StreamLine = { delta?: string; done?: boolean; model?: string; error?: string };
// keep in sync with the server side defaults (searx/ai_summary.py)
const MAX_CONTEXT_ITEMS = 5;
const MAX_HISTORY_MESSAGES = 12;
const MAX_TITLE_LENGTH = 300;
const MAX_SNIPPET_LENGTH = 1000;
/**
* Fills the AI summary placeholder (rendered by the ai_summary plugin of the
* server) by streaming an answer from the /ai_summary endpoint, with an
* expand button and a follow-up question chat.
*/
export default class AiSummary extends Plugin {
private readonly messages: Message[] = [];
private context: ContextItem[] = [];
private controller: AbortController | undefined;
public constructor() {
super("ai_summary");
}
protected async run(): Promise<void> {
const box = document.getElementById("ai_summary");
if (!box) return;
try {
const { query } = box.dataset;
if (!query) return;
if (box.dataset.grounding === "1") {
this.context = AiSummary.collectContext();
}
this.wireControls(box);
this.messages.push({ role: "user", content: query });
await this.exchange(box);
} catch (error) {
// never fail silently, always leave a message in the summary box
AiSummary.showError(box, error);
}
}
protected async post(): Promise<void> {
// noop
}
/**
* Scrape the top search results from the DOM as grounding context.
*/
private static collectContext(): ContextItem[] {
const items: ContextItem[] = [];
for (const article of document.querySelectorAll<HTMLElement>("#urls article.result")) {
if (items.length >= MAX_CONTEXT_ITEMS) break;
const link = article.querySelector<HTMLAnchorElement>("h3 a");
if (!link) continue;
items.push({
title: (link.textContent ?? "").trim().slice(0, MAX_TITLE_LENGTH),
url: link.href,
snippet: (article.querySelector(".content")?.textContent ?? "").trim().slice(0, MAX_SNIPPET_LENGTH)
});
}
return items;
}
private wireControls(box: HTMLElement): void {
const body = box.querySelector<HTMLElement>(".ai-summary-body");
const moreButton = box.querySelector<HTMLElement>(".ai-summary-more");
const followupForm = box.querySelector<HTMLFormElement>(".ai-summary-followup");
assertElement(body);
assertElement(moreButton);
assertElement(followupForm);
moreButton.addEventListener("click", () => {
const collapsed = body.classList.toggle("collapsed");
moreButton.textContent = collapsed
? (moreButton.dataset.btnTextCollapsed ?? "")
: (moreButton.dataset.btnTextNotCollapsed ?? "");
followupForm.classList.toggle("invisible", collapsed);
});
followupForm.addEventListener("submit", (event: Event) => {
event.preventDefault();
const input = followupForm.querySelector<HTMLInputElement>("input");
assertElement(input);
const question = input.value.trim();
if (!question || this.controller) return;
input.value = "";
this.messages.push({ role: "user", content: question });
// the server rejects too long histories, drop the oldest messages
this.messages.splice(0, this.messages.length - (MAX_HISTORY_MESSAGES - 1));
const questionElement = Object.assign(document.createElement("p"), {
textContent: question,
className: "ai-summary-question"
});
box.querySelector(".ai-summary-answers")?.append(questionElement);
void this.exchange(box);
});
}
/**
* Send the message history to /ai_summary and stream the answer into a new
* block in the answer area.
*/
private async exchange(box: HTMLElement): Promise<void> {
const answers = box.querySelector<HTMLElement>(".ai-summary-answers");
assertElement(answers);
const block = Object.assign(document.createElement("p"), {
className: "ai-summary-content typing"
});
answers.append(block);
const controller = new AbortController();
this.controller = controller;
let text = "";
const handleLine = (line: string): void => {
if (!line.trim()) return;
const data = JSON.parse(line) as StreamLine;
if (data.error) {
throw new Error(data.error);
}
if (data.delta) {
text += data.delta;
block.textContent = text;
this.updateMoreButton(box);
}
if (data.done && data.model) {
const modelElement = box.querySelector<HTMLElement>(".ai-summary-model");
if (modelElement) modelElement.textContent = data.model;
}
};
try {
const res = await fetch("./ai_summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: this.messages, context: this.context }),
signal: controller.signal
});
if (!res.ok) {
// the status is shown to the user: it is often the only thing
// available to diagnose a failure on a device without dev tools
throw new Error(`HTTP ${res.status}`);
}
if (res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
// biome-ignore lint/performance/noAwaitInLoops: chunks of a stream are read sequentially
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) handleLine(line);
}
handleLine(buffer);
} else {
// some webviews (e.g. Brave iOS) wrap fetch and don't expose a
// streaming body: no typing effect, render the answer in one go
const body = await res.text();
for (const line of body.split("\n")) handleLine(line);
}
this.messages.push({ role: "assistant", content: text });
} catch (error) {
AiSummary.showError(box, error);
} finally {
block.classList.remove("typing");
this.controller = undefined;
this.updateMoreButton(box);
}
}
private static showError(box: HTMLElement, error: unknown): void {
console.error("Error loading AI summary:", error);
const message = settings.translations?.error_loading_ai_summary ?? "Error loading the AI summary";
const reason = error instanceof Error && error.message ? ` (${error.message})` : "";
const errorElement = Object.assign(document.createElement("div"), {
textContent: `${message}${reason}`,
className: "dialog-error"
});
errorElement.setAttribute("role", "alert");
(box.querySelector<HTMLElement>(".ai-summary-answers") ?? box).append(errorElement);
}
/**
* Show the expand button as soon as the (collapsed) body overflows.
*/
private updateMoreButton(box: HTMLElement): void {
const body = box.querySelector<HTMLElement>(".ai-summary-body");
const moreButton = box.querySelector<HTMLElement>(".ai-summary-more");
if (!(body && moreButton)) return;
if (!body.classList.contains("collapsed") || body.scrollHeight > body.clientHeight + 4) {
moreButton.classList.remove("invisible");
}
}
}
+7
View File
@@ -34,6 +34,13 @@ ready(() => {
where: [Endpoints.results] where: [Endpoints.results]
}); });
} }
if (settings.plugins?.includes("ai_summary")) {
load(() => import("./plugin/AiSummary.ts").then(({ default: Plugin }) => new Plugin()), {
on: "endpoint",
where: [Endpoints.results]
});
}
}); });
ready( ready(
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
@keyframes ai-summary-caret {
50% {
opacity: 0;
}
}
.ai-summary {
display: flex;
flex-direction: column;
gap: 0.5rem;
.ai-summary-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
.ai-summary-title {
font-size: 0.9em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ai-summary-model {
font-size: 0.8em;
opacity: 0.7;
}
}
.ai-summary-body.collapsed {
max-height: 8em;
overflow: hidden;
mask-image: linear-gradient(to bottom, #000 60%, transparent 100%);
}
.ai-summary-answers {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ai-summary-content {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
&.typing::after {
content: "▍";
animation: ai-summary-caret 1s step-end infinite;
}
}
.ai-summary-question {
margin: 0;
font-weight: bold;
}
.ai-summary-more {
align-self: center;
border: none;
.show-content-button();
}
.ai-summary-followup {
display: flex;
gap: 0.5rem;
input {
flex: 1;
padding: 5px 10px;
border: 1px solid var(--color-sidebar-border);
background: var(--color-answer-background);
color: var(--color-answer-font);
.rounded-corners-tiny;
}
button {
border: none;
.show-content-button();
}
}
.ai-summary-disclaimer {
margin: 0;
font-size: 0.8em;
opacity: 0.7;
}
}
+4 -2
View File
@@ -35,7 +35,8 @@ table {
width: 300px; width: 300px;
} }
input[type="text"] { input[type="text"],
input[type="password"] {
width: 13.25rem; width: 13.25rem;
color: var(--color-toolkit-input-text-font); color: var(--color-toolkit-input-text-font);
border: none; border: none;
@@ -65,7 +66,8 @@ table {
width: 15em; width: 15em;
select, select,
input[type="text"] { input[type="text"],
input[type="password"] {
font-size: inherit !important; font-size: inherit !important;
margin-top: 0; margin-top: 0;
.ltr-margin-right(1rem); .ltr-margin-right(1rem);
+1
View File
@@ -13,6 +13,7 @@
@import "stats.less"; @import "stats.less";
@import "result_templates.less"; @import "result_templates.less";
@import "weather.less"; @import "weather.less";
@import "ai_summary.less";
// for index.html template // for index.html template
@import "index.less"; @import "index.less";
+1
View File
@@ -25,3 +25,4 @@ Settings
settings_outgoing settings_outgoing
settings_categories_as_tabs settings_categories_as_tabs
settings_plugins settings_plugins
settings_ai_summary
+171
View File
@@ -0,0 +1,171 @@
.. _settings ai_summary:
===============
``ai_summary:``
===============
.. sidebar:: Further reading ..
- :ref:`ai_summary plugin`
- :ref:`settings plugins`
- :ref:`settings preferences`
Configuration of the :ref:`AI Summary plugin <ai_summary plugin>`, which shows a
short AI generated answer above the search results.
The text is produced by an LLM server that you run; SearXNG ships no model and
contacts no AI provider of its own. Any server implementing the `OpenAI chat
completions API`_ works: `Ollama`_, vLLM, llama.cpp, LM Studio, Hugging Face
TGI and others. The plugin is not activated by default.
.. _ai_summary quickstart:
Quickstart
==========
A local setup on the same machine as SearXNG, in four steps.
**1. Install Ollama**
.. code:: sh
curl -fsSL https://ollama.com/install.sh | sh
**2. Download a model**
.. code:: sh
ollama pull gemma3:4b
``gemma3:4b`` needs roughly 4 GB of memory and runs on CPU if you have no GPU.
On a smaller machine use ``gemma3:1b``; any model in the `Ollama library`_
works.
**3. Configure SearXNG**
Add this to your ``settings.yml``:
.. code:: yaml
ai_summary:
base_url: "http://127.0.0.1:11434"
model: "gemma3:4b"
plugins:
searx.plugins.ai_summary.SXNGPlugin:
active: true
# keep the plugins you already had, see the warning below
searx.plugins.calculator.SXNGPlugin: {active: true}
searx.plugins.hash_plugin.SXNGPlugin: {active: true}
searx.plugins.self_info.SXNGPlugin: {active: true}
searx.plugins.unit_converter.SXNGPlugin: {active: true}
searx.plugins.ahmia_filter.SXNGPlugin: {active: true}
searx.plugins.hostnames.SXNGPlugin: {active: true}
searx.plugins.time_zone.SXNGPlugin: {active: true}
searx.plugins.tracker_url_remover.SXNGPlugin: {active: true}
searx.plugins.infinite_scroll.SXNGPlugin: {active: false}
searx.plugins.oa_doi_rewrite.SXNGPlugin: {active: false}
searx.plugins.tor_check.SXNGPlugin: {active: false}
.. warning::
A ``plugins:`` block **replaces** the default list, it is not merged into it
(:ref:`settings plugins`). Listing only the AI Summary plugin switches
every other plugin off, which is why the block above repeats the defaults --
drop the lines for plugins you do not want.
**4. Restart SearXNG** and search for something.
The summary appears above the results while it is still being written.
Options
=======
Only ``base_url`` is required. A model is needed too, but if ``model`` is left
empty the first entry of ``models`` is used.
.. code:: yaml
ai_summary:
base_url: "http://127.0.0.1:11434"
model: "gemma3:4b"
grounding: true
.. autoclass:: searx.ai_summary.SettingsAISummary
:members:
Servers that require authentication
===================================
vLLM and llama.cpp started with ``--api-key``, a gateway such as LiteLLM, or an
LLM server behind an authenticating reverse proxy all expect a key. Set it
with ``api_key``:
.. code:: yaml
ai_summary:
base_url: "http://127.0.0.1:8000"
api_key: "sk-..."
model: "gemma3:4b"
The key is sent as an ``Authorization: Bearer`` header and only to the server in
``base_url``. Users who configure a server of their own in the
``ai_summary_server`` preference never receive it; they set their own key in the
``ai_summary_api_key`` preference.
SearXNG has no separate secret store, so the key is held in ``settings.yml`` --
that file should be readable only by the user SearXNG runs as.
.. _ai_summary grounding:
Grounding
=========
With ``grounding`` enabled, which is the default, the query **and the top search
results** (title, URL and snippet, at most ``max_context_items`` of them) are
sent to the LLM server, and the answer reflects what the search found. With it
disabled only the query is sent and the model answers from its training data.
Users can change this in the ``ai_summary_grounding`` preference.
What leaves the network therefore depends on where the LLM server runs: with a
server on localhost or in the local network, nothing does.
Public SearXNG instances
========================
The server URL, model and API key are user preferences so that someone running
SearXNG at home can switch models or debug their LLM server from the
preferences page, without editing ``settings.yml`` and restarting.
On public SearXNG instances, those same preferences let any visitor choose the
address the summary request is sent to. The request is made by the SearXNG
host, so a visitor can use it to reach machines on your network that they have
no route to themselves -- an `SSRF`_ vector.
.. attention::
Lock ``ai_summary_server`` on any SearXNG instance that is reachable by
people outside your household.
Locking a preference makes SearXNG use your configured value and ignore the
user's (:ref:`settings preferences`):
.. code:: yaml
preferences:
lock:
- ai_summary_server
- ai_summary_api_key
- ai_summary_model
- ai_summary_grounding
``ai_summary_server`` is the one that matters: locking it closes the SSRF
vector. Locking ``ai_summary_api_key`` additionally stops visitors making your
SearXNG instance send an ``Authorization`` header of their choosing to a host of
their choosing. ``ai_summary_model`` and ``ai_summary_grounding`` are about
cost and consistency rather than security.
.. _Ollama: https://ollama.com/
.. _Ollama library: https://ollama.com/library
.. _OpenAI chat completions API: https://platform.openai.com/docs/api-reference/chat
.. _SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
+103
View File
@@ -0,0 +1,103 @@
.. _ai_summary plugin:
==========
AI Summary
==========
.. sidebar:: Further reading ..
- :ref:`Configuration <settings ai_summary>`
- :ref:`dev plugin`
- :ref:`result types`
The AI Summary plugin shows a generated answer above the ordinary search
results. The text comes from an LLM server run by the administrator, which
speaks the `OpenAI chat completions API`_ -- `Ollama`_, Hugging Face TGI,
LiteLLM, vLLM, llama.cpp and anything else implementing that specification.
See :ref:`its configuration <settings ai_summary>` for how to set one up.
The summary is generated asynchronously: the result page is delivered without
delay and carries an empty placeholder, which the browser fills from a second,
streaming request.
Request flow
============
.. _ai_summary dataflow:
.. kernel-render:: DOT
:alt: Data flow between browser, SearXNG and the LLM server
:caption: A search that produces a summary: two requests, not one
digraph ai_summary {
rankdir=LR;
graph [fontname="sans-serif", ranksep=1.1, nodesep=0.4];
node [fontname="sans-serif", fontsize=11, shape=box, style="rounded,filled",
fillcolor="#f4f4f4", color="#999999"];
edge [fontname="sans-serif", fontsize=9, color="#666666"];
browser [label="browser"];
searxng [label="SearXNG"];
engines [label="search engines", fillcolor="#ffffff"];
llm [label="LLM server\nOllama, vLLM, ...", fillcolor="#ffffff"];
browser -> searxng [label=" 1 GET /search"];
searxng -> engines [label=" 2 query"];
searxng -> browser [label=" 3 result page,\l empty summary box\l", constraint=false];
browser -> searxng [label=" 4 POST /ai_summary\l (query + results)\l"];
searxng -> llm [label=" 5 POST /v1/chat/completions"];
llm -> searxng [label=" 6 SSE token stream", constraint=false];
searxng -> browser [label=" 7 NDJSON token stream", constraint=false];
}
Steps 1 to 3 are an ordinary SearXNG search. :py:obj:`SXNGPlugin.post_search
<searx.plugins.ai_summary.SXNGPlugin.post_search>` adds an empty
:py:obj:`searx.result_types.AiSummary` placeholder to the answer area and
returns; the result page is not delayed.
Steps 4 to 7 run in the browser once the page is rendered.
``client/simple/src/js/plugin/AiSummary.ts`` posts to the ``/ai_summary``
endpoint (registered in :py:obj:`searx.webapp`), which opens a streaming
request to the LLM server and re-emits the tokens as they arrive.
The two streams use different formats. The LLM server sends `SSE`_ --
``data: {...}`` lines terminated by ``data: [DONE]``. SearXNG re-emits them to
the browser as `NDJSON`_: one JSON object per line, ``{"delta": "..."}`` for
each chunk of text and a final ``{"done": true}``.
When no summary is generated
============================
:py:obj:`SXNGPlugin.post_search
<searx.plugins.ai_summary.SXNGPlugin.post_search>` adds no placeholder for:
- page two and beyond
- categories other than *general*
- non-HTML output formats (the JSON, CSV and RSS APIs)
- queries an engine already answered with an infobox (wikipedia, wikidata) or
an instant answer (e.g. ddg definitions)
- an empty query, or no LLM server configured
API keys
========
The administrator's ``api_key`` is sent only to the configured ``base_url``.
Users who point the ``ai_summary_server`` preference at a server of their own
authenticate it with their own ``ai_summary_api_key`` preference, which is
stored in a cookie and excluded from the preferences URL; the administrator's
key is never sent to such a server. A server URL carrying credentials in its
userinfo is ignored, and the administrator's default is used instead.
:py:obj:`_server_api_key <searx.plugins.ai_summary._server_api_key>` implements
the rule.
Reference
=========
.. automodule:: searx.plugins.ai_summary
:members:
.. _Ollama: https://ollama.com/
.. _OpenAI chat completions API: https://platform.openai.com/docs/api-reference/chat
.. _SSE: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
.. _NDJSON: https://github.com/ndjson/ndjson-spec
+1
View File
@@ -7,6 +7,7 @@ Built-in Plugins
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
ai_summary
calculator calculator
hash_plugin hash_plugin
hostnames hostnames
+3
View File
@@ -33,6 +33,9 @@ class SettingsPref(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
"theme", "theme",
"results_on_new_tab", "results_on_new_tab",
"doi_resolver", "doi_resolver",
"ai_summary_server",
"ai_summary_model",
"ai_summary_grounding",
"simple_style", "simple_style",
"center_alignment", "center_alignment",
"query_in_title", "query_in_title",
+135
View File
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Implementations needed for the AI Summary plugin
(:py:obj:`searx.plugins.ai_summary`)."""
# pylint: disable=too-few-public-methods
# Struct fields aren't discovered in Python 3.14
# - https://github.com/searxng/searxng/issues/5284
from __future__ import annotations
__all__ = ["SettingsAISummary", "MODELS", "model_choices", "build_chat_messages"]
import msgspec
DEFAULT_SYSTEM_PROMPT = (
"You are a search assistant. Answer the user's search query concisely in"
" a few short paragraphs of plain text. If you are unsure or don't know"
" the answer, say so."
)
DEFAULT_SYSTEM_PROMPT_GROUNDED = (
"You are a search assistant. Answer the user's search query concisely in"
" a few short paragraphs of plain text, using the following search results"
" as context when they are relevant. If you are unsure or don't know the"
" answer, say so.\n\nSearch results:\n\n{context}"
)
MODELS: list[str] = []
"""List of model names a user can select from. Populated once at
application setup by :py:obj:`searx.plugins.ai_summary.SXNGPlugin.init`."""
class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
"""Options for configuring the AI Summary plugin.
.. code:: yaml
ai_summary:
base_url: "http://127.0.0.1:11434"
model: "llama3.2:3b"
models:
- "llama3.2:3b"
- "gemma3:4b"
"""
base_url: str = ""
"""Default base URL of the LLM server (e.g. ``http://127.0.0.1:11434``
for Ollama). Any server that implements the OpenAI chat completions API
works (Ollama, vLLM, llama.cpp, LM Studio, Hugging Face TGI, ...). Users
can set their own server URL in the preferences (``ai_summary_server``)
unless that preference is locked."""
api_key: str = ""
"""Optional API key of the LLM server in
:py:obj:`SettingsAISummary.base_url`, sent in an ``Authorization: Bearer``
header. Needed by servers that require authentication, e.g. vLLM or
llama.cpp started with ``--api-key``, or an LLM server behind an
authenticating reverse proxy.
This key is **only** sent to :py:obj:`SettingsAISummary.base_url`: a user
who points the ``ai_summary_server`` preference at a server of their own
gets no ``Authorization`` header from it, so the key can't be captured by
a third party. For their own server, users configure their own key in the
``ai_summary_api_key`` preference (see
:py:obj:`searx.plugins.ai_summary._server_api_key`)."""
model: str = ""
"""Name of the default model (e.g. ``llama3.2:3b``). If empty, the first
entry of :py:obj:`SettingsAISummary.models` is used. Users can set their
own model in the preferences (``ai_summary_model``) unless that preference
is locked."""
models: list[str] = []
"""List of model names suggested to the user in the preferences. If empty
and :py:obj:`SettingsAISummary.base_url` is set, the list is requested
once at application setup from the LLM server (``GET /v1/models``)."""
grounding: bool = True
"""Default of the ``ai_summary_grounding`` user preference: ground the
summary on the search results. Grounded summaries are more accurate and
more current at a moderate extra cost (the search results are sent along
with the query, so the prompt is longer). Users can still opt in/out in
the preferences unless that preference is locked."""
connect_timeout: float = 5.0
"""Timeout (seconds) to establish a TCP connection to the LLM server."""
read_timeout: float = 30.0
"""Maximum gap (seconds) between two chunks of the token stream."""
stream_timeout: float = 120.0
"""Wall clock limit (seconds) for one completion."""
max_context_items: int = 5
"""Maximum number of search results accepted as grounding context."""
max_history_messages: int = 12
"""Maximum number of messages (follow-up chat history) per request."""
max_message_length: int = 4000
"""Maximum length (characters) of a single message or context snippet."""
system_prompt: str = DEFAULT_SYSTEM_PROMPT
"""System prompt used when the *grounding* preference is off."""
system_prompt_grounded: str = DEFAULT_SYSTEM_PROMPT_GROUNDED
"""System prompt used when the *grounding* preference is on. The
placeholder ``{context}`` is replaced by an enumeration of the search
results sent along with the query."""
def model_choices() -> list[str]:
"""Model names a user can select from in the preferences."""
return list(MODELS)
def build_chat_messages(
cfg: SettingsAISummary,
messages: list[dict[str, str]],
context: list[dict[str, str]] | None = None,
) -> list[dict[str, str]]:
"""Build the message list for the chat completions request from the
(already validated) request ``messages``, prepending a system prompt. When
``context`` items are given, the grounded system prompt is used and the
context items are serialized into its ``{context}`` placeholder."""
if context:
ctx_lines = [
f"[{no}] {item.get('title', '')}{item.get('snippet', '')} ({item.get('url', '')})"
for no, item in enumerate(context[: cfg.max_context_items], start=1)
]
system_prompt = cfg.system_prompt_grounded.replace("{context}", "\n".join(ctx_lines))
else:
system_prompt = cfg.system_prompt
return [{"role": "system", "content": system_prompt}, *messages]
+1 -1
View File
@@ -42,7 +42,7 @@ class PluginInfo:
description: str description: str
"""Short description of the *answerer*.""" """Short description of the *answerer*."""
preference_section: t.Literal["general", "ui", "privacy", "query"] | None = "general" preference_section: t.Literal["general", "ui", "privacy", "query", "ai"] | None = "general"
"""Section (tab/group) in the preferences where this plugin is shown to the """Section (tab/group) in the preferences where this plugin is shown to the
user. user.
+386
View File
@@ -0,0 +1,386 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Implementation of the AI Summary plugin, which shows a generated answer above
the search results. The answer comes from an LLM server that implements the
`OpenAI chat completions API`_ (Ollama, vLLM, llama.cpp, LM Studio, Hugging Face
TGI, ...) and that the administrator runs.
- :ref:`ai_summary plugin` describes the design and the request flow.
- :ref:`settings ai_summary` describes how to configure it.
This module holds the plugin itself and the ``/ai_summary`` endpoint
(:py:obj:`ai_summary_view`, registered in :py:obj:`searx.webapp`). The endpoint
streams the answer to the browser, so that the result page is never delayed by
the LLM; :py:obj:`SXNGPlugin.post_search` only adds an empty
:py:obj:`searx.result_types.AiSummary` placeholder for the client to fill.
Settings of the ``ai_summary:`` section are defined in
:py:obj:`searx.ai_summary.SettingsAISummary`.
.. _OpenAI chat completions API: https://platform.openai.com/docs/api-reference/chat
"""
import typing as t
import json
import logging
import re
import time
from urllib.parse import urlparse
import flask
import httpx
from flask_babel import gettext
from searx import get_setting
from searx.ai_summary import SettingsAISummary, build_chat_messages
from searx.extended_types import sxng_request
from searx.result_types import EngineResults
import searx.ai_summary
from . import Plugin, PluginInfo
if t.TYPE_CHECKING:
from searx.search import SearchWithPlugins
from searx.extended_types import SXNG_Request
from . import PluginCfg
VALID_ROLES = ("user", "assistant")
# Model names differ per provider: "gemma3:4b" (Ollama), "bedrock/anthropic.
# claude-3-5-sonnet" (a gateway's routing prefix), "gemini-1.5-pro@001" (a
# pinned version). The pattern accepts those and rejects anything that could
# change the meaning of the request body it is placed into.
MODEL_NAME_REGEXP = re.compile(r"[A-Za-z0-9._:/@-]{1,128}")
log = logging.getLogger("searx.plugins.ai_summary")
UPSTREAM_RETRIES = 1
"""How often a request to the LLM server is repeated when the server does not
answer in time.
An idle LLM server unloads the model, and loads it again on the next request --
which can take longer than :py:obj:`read_timeout
<searx.ai_summary.SettingsAISummary.read_timeout>`, because no byte of the
response is sent while the model is loading. The request that runs into this
is also the request that starts the load, so repeating it usually succeeds."""
def _get_client(base_url: str, cfg: SettingsAISummary, api_key: str = "") -> httpx.Client:
"""HTTP client for one request to the LLM server at ``base_url``. The
``api_key`` (if any) is sent in an ``Authorization: Bearer`` header, see
:py:obj:`_server_api_key`."""
# the OpenAI API paths are prefixed with /v1, unless the base URL already
# points into an API prefix
base_url = base_url.rstrip("/")
if not base_url.endswith("/v1"):
base_url += "/v1"
return httpx.Client(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}"} if api_key else None,
timeout=httpx.Timeout(connect=cfg.connect_timeout, read=cfg.read_timeout, write=10.0, pool=10.0),
)
def _valid_server(url: str) -> bool:
try:
parsed = urlparse(url)
except ValueError:
return False
return parsed.scheme in ("http", "https") and bool(parsed.netloc) and len(url) <= 256
def _server_id(url: str) -> tuple[str, str, int, str] | None:
"""Identity of an LLM server URL (scheme, host, port, path) for comparing
two URLs, or ``None`` if the URL is unusable. The ``/v1`` API prefix is
not part of the identity, :py:obj:`_get_client` appends it when missing."""
try:
parsed = urlparse(url)
except ValueError:
return None
# .hostname (not .netloc) drops the userinfo, so that a server URL like
# http://llm.example.org@untrusted.example.org/ is identified by the host
# the request is actually sent to (untrusted.example.org)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
path = parsed.path.rstrip("/")
if path.endswith("/v1"):
path = path[: -len("/v1")].rstrip("/")
return (parsed.scheme, parsed.hostname.lower(), port, path)
def _server_api_key(cfg: SettingsAISummary, server: str, user_api_key: str = "") -> str:
"""The API key to send to ``server``:
- the administrator's :py:obj:`cfg.api_key
<searx.ai_summary.SettingsAISummary.api_key>` if ``server`` *is* the
administrator's server (:py:obj:`cfg.base_url
<searx.ai_summary.SettingsAISummary.base_url>`),
- otherwise the user's own ``ai_summary_api_key`` preference, which belongs
to the server in the user's own ``ai_summary_server`` preference.
The administrator's key is never sent to a server a user configured --
that would hand every user of the instance a way to capture it."""
server_id = _server_id(server)
if server_id is not None and server_id == _server_id(cfg.base_url):
return cfg.api_key
return user_api_key
def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str:
"""The LLM server URL for this request: the user's ``ai_summary_server``
preference, or the administrator's default."""
server = str(request.preferences.get_value("ai_summary_server") or "").strip()
# Credentials are stripped from a user's server URL: httpx turns them into
# an Authorization header, and a user should not be able to make SearXNG
# send a header of their choosing to a host of their choosing. An
# administrator can still use credentials in the configured base_url (e.g.
# an LLM server behind basic auth).
if server and "@" in urlparse(server).netloc:
server = ""
return server or cfg.base_url
class SXNGPlugin(Plugin):
"""Plugin that adds the AI summary placeholder to the result page, the
``/ai_summary`` endpoint itself is registered in :py:obj:`searx.webapp`."""
id = "ai_summary"
def __init__(self, plg_cfg: "PluginCfg"):
super().__init__(plg_cfg)
self.info = PluginInfo(
id=self.id,
name=gettext("AI Summary"),
description=gettext(
"Show an AI generated summary of the search query on top of the"
" result page (uses a local LLM server, see the settings below)."
),
preference_section="ai",
)
def init(self, app: "flask.Flask") -> bool:
cfg: SettingsAISummary = get_setting("ai_summary")
if cfg.base_url:
searx.ai_summary.MODELS = list(cfg.models) or self._probe_models(cfg)
if not cfg.model and searx.ai_summary.MODELS:
cfg.model = searx.ai_summary.MODELS[0]
if cfg.model and cfg.model not in searx.ai_summary.MODELS:
searx.ai_summary.MODELS.insert(0, cfg.model)
return True
def _probe_models(self, cfg: SettingsAISummary) -> list[str]:
"""Request the list of models from the LLM server (``GET
/v1/models``). The server might not be up when SearXNG starts, a
failing probe only leaves the model suggestion list empty."""
try:
with _get_client(cfg.base_url, cfg, cfg.api_key) as client:
resp = client.get("/models")
resp.raise_for_status()
models = [model["id"] for model in resp.json().get("data", [])]
except (httpx.HTTPError, ValueError, KeyError) as exc:
self.log.warning("can't request model list from %s: %s", cfg.base_url, exc)
models = []
return models or ([cfg.model] if cfg.model else [])
def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults | None:
results = EngineResults()
sq = search.search_query
cfg: SettingsAISummary = get_setting("ai_summary")
skip = (
sq.pageno > 1
# post_search is also called for the json, csv and rss formats,
# the placeholder is only useful on the HTML result page
or request.form.get("format", "html") != "html"
or "general" not in sq.categories
# an infobox (e.g. wikipedia / wikidata) or an instant answer
# (e.g. ddg definitions) most likely already answers the query
or bool(search.result_container.infoboxes)
or bool(search.result_container.answers)
or not sq.query.strip()
# without an LLM server (user preference or admin default)
# there is nothing to show
or not _user_server(request, cfg)
)
if skip:
return None
grounding = bool(request.preferences.get_value("ai_summary_grounding"))
results.add(results.types.AiSummary(query=sq.query, grounding=grounding))
return results
def _bad_request(msg: str) -> flask.Response:
return flask.Response(json.dumps({"error": msg}), status=400, mimetype="application/json")
def _validate_messages(messages: t.Any, cfg: SettingsAISummary) -> list[dict[str, str]]:
if not isinstance(messages, list) or not messages or len(messages) > cfg.max_history_messages:
raise ValueError("invalid messages")
for msg in messages:
if not isinstance(msg, dict) or msg.keys() != {"role", "content"}:
raise ValueError("invalid message")
if msg["role"] not in VALID_ROLES or not isinstance(msg["content"], str):
raise ValueError("invalid message")
if not msg["content"].strip() or len(msg["content"]) > cfg.max_message_length:
raise ValueError("invalid message")
if messages[-1]["role"] != "user":
raise ValueError("last message is not a user message")
return messages
def _validate_context(context: t.Any, cfg: SettingsAISummary) -> list[dict[str, str]]:
if not isinstance(context, list) or len(context) > cfg.max_context_items:
raise ValueError("invalid context")
for item in context:
if not isinstance(item, dict) or not item.keys() <= {"title", "url", "snippet"}:
raise ValueError("invalid context item")
for val in item.values():
if not isinstance(val, str) or len(val) > cfg.max_message_length:
raise ValueError("invalid context item")
return context
def _validate_payload(payload: t.Any, cfg: SettingsAISummary) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Validate the request body of the ``/ai_summary`` endpoint and return
the ``messages`` and ``context`` lists. Raises a :py:obj:`ValueError` for
any malformed payload."""
if not isinstance(payload, dict):
raise ValueError("payload is not an object")
return _validate_messages(payload.get("messages"), cfg), _validate_context(payload.get("context", []), cfg)
def _open_upstream(client: httpx.Client, payload: dict[str, t.Any]) -> tuple[t.Any, t.Any]:
"""Start the streaming completion on the LLM server.
Returns the (already entered) stream context and the response, or
``(None, None)`` if no usable response was received.
The request is repeated (:py:obj:`UPSTREAM_RETRIES`) when the server did not
answer in time, and when it answered ``5xx`` -- both mean *not right now*,
and the most common reason is a model that is still being loaded. A ``4xx``
is not repeated: a wrong API key or an unknown model name does not become
right when asked twice."""
for attempt in range(UPSTREAM_RETRIES + 1):
stream_ctx = client.stream("POST", "/chat/completions", json=payload)
try:
resp = stream_ctx.__enter__() # pylint: disable=unnecessary-dunder-call
except httpx.TransportError as exc:
if attempt < UPSTREAM_RETRIES:
log.debug("LLM server did not answer (%s), asking again", exc)
continue
log.warning("LLM server did not answer: %s", exc)
return None, None
except httpx.HTTPError as exc:
log.warning("request to the LLM server failed: %s", exc)
return None, None
if resp.status_code == 200:
return stream_ctx, resp
# the body of an error response is short and usually names the cause,
# e.g. an unknown model; without it a misconfiguration is invisible
detail = ""
try:
resp.read()
detail = resp.text.strip()[:200]
except (httpx.HTTPError, UnicodeDecodeError): # pragma: no cover
pass
stream_ctx.__exit__(None, None, None)
# 5xx is the server saying it is not able to answer *right now* -- a
# model still loading, a gateway with no upstream yet. 4xx is the
# server saying the request is wrong, which a second one would be too.
if resp.status_code >= 500 and attempt < UPSTREAM_RETRIES:
log.debug("LLM server replied HTTP %s (%s), asking again", resp.status_code, detail)
continue
log.warning("LLM server responded with HTTP %s %s", resp.status_code, detail)
return None, None
return None, None # pragma: no cover - the loop always returns
def ai_summary_view() -> flask.Response:
"""Stream an AI generated answer for the messages in the request body,
response is NDJSON: ``{"delta": ..}`` lines followed by one final
``{"done": true, ..}`` line."""
cfg: SettingsAISummary = get_setting("ai_summary")
if SXNGPlugin.id not in sxng_request.user_plugins:
return flask.Response(json.dumps({"error": "plugin is not enabled"}), status=403, mimetype="application/json")
try:
messages, context = _validate_payload(sxng_request.get_json(force=True, silent=True), cfg)
except ValueError as exc:
return _bad_request(str(exc))
server = _user_server(sxng_request, cfg)
if not _valid_server(server):
return _bad_request("no valid LLM server configured")
model = str(sxng_request.preferences.get_value("ai_summary_model") or "").strip() or cfg.model
if not MODEL_NAME_REGEXP.fullmatch(model):
return _bad_request("no valid model configured")
chat_payload = {
"model": model,
"messages": build_chat_messages(cfg, messages, context),
"stream": True,
}
# open the upstream connection before streaming, a connection error is
# reported as HTTP 502 instead of a line in an already started stream
user_api_key = str(sxng_request.preferences.get_value("ai_summary_api_key") or "").strip()
client = _get_client(server, cfg, _server_api_key(cfg, server, user_api_key))
stream_ctx, upstream = _open_upstream(client, chat_payload)
if stream_ctx is None or upstream is None:
client.close()
return flask.Response(json.dumps({"error": "upstream error"}), status=502, mimetype="application/json")
# from here on nothing must be read from the request context, the
# generator runs after the request context has been torn down
def ndjson(obj: dict[str, t.Any]) -> bytes:
# the generator bypasses flask's response encoding (direct_passthrough)
return (json.dumps(obj) + "\n").encode()
def generate():
start = time.monotonic()
try:
# the upstream is a SSE stream: "data: {..}" lines, terminated by
# a "data: [DONE]" line
for line in upstream.iter_lines():
if time.monotonic() - start > cfg.stream_timeout:
yield ndjson({"done": True, "error": "timeout"})
return
line = line.strip()
if not line or line.startswith(":") or not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if payload == "[DONE]":
break
data = json.loads(payload)
choices = data.get("choices") or [{}]
delta = choices[0].get("delta", {}).get("content") or ""
if delta:
yield ndjson({"delta": delta})
yield ndjson({"done": True, "model": model})
except (httpx.HTTPError, ValueError) as exc:
log.warning("error while streaming from the LLM server: %s", exc)
yield ndjson({"done": True, "error": "upstream error"})
finally:
stream_ctx.__exit__(None, None, None)
client.close()
return flask.Response(
generate(),
mimetype="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
direct_passthrough=True,
)
+27 -2
View File
@@ -49,10 +49,14 @@ class ValidationException(Exception):
class Setting: class Setting:
"""Base class of user settings""" """Base class of user settings"""
def __init__(self, default_value: t.Any, locked: bool = False): def __init__(self, default_value: t.Any, locked: bool = False, secret: bool = False):
super().__init__() super().__init__()
self.value: t.Any = default_value self.value: t.Any = default_value
self.locked: bool = locked self.locked: bool = locked
self.secret: bool = secret
"""The value is a credential: it is not included in the preferences URL
(:py:obj:`Preferences.get_as_url_params`), which users copy around to
transfer or share their preferences."""
def parse(self, data: str): def parse(self, data: str):
"""Parse ``data`` and store the result at ``self.value`` """Parse ``data`` and store the result at ``self.value``
@@ -462,6 +466,27 @@ class Preferences:
locked="doi_resolver" in self.cfg.lock, locked="doi_resolver" in self.cfg.lock,
choices=DOI_RESOLVERS, choices=DOI_RESOLVERS,
), ),
# empty values fall back to the administrator's defaults in the
# ai_summary: section (searx.ai_summary.SettingsAISummary)
'ai_summary_server': StringSetting(
"",
locked="ai_summary_server" in self.cfg.lock,
),
'ai_summary_api_key': StringSetting(
"",
locked="ai_summary_api_key" in self.cfg.lock,
# a user's API key is only sent to a server the user configured
# themselves, and it is never part of the preferences URL
secret=True,
),
'ai_summary_model': StringSetting(
"",
locked="ai_summary_model" in self.cfg.lock,
),
'ai_summary_grounding': BooleanSetting(
get_setting("ai_summary").grounding,
locked="ai_summary_grounding" in self.cfg.lock,
),
'simple_style': EnumStringSetting( 'simple_style': EnumStringSetting(
get_setting("ui.theme_args.simple_style"), get_setting("ui.theme_args.simple_style"),
locked="simple_style" in self.cfg.lock, locked="simple_style" in self.cfg.lock,
@@ -498,7 +523,7 @@ class Preferences:
"""Return preferences as URL parameters""" """Return preferences as URL parameters"""
settings_kv = {} settings_kv = {}
for k, v in self.key_value_settings.items(): for k, v in self.key_value_settings.items():
if v.locked: if v.locked or v.secret:
continue continue
if isinstance(v, MultipleChoiceSetting): if isinstance(v, MultipleChoiceSetting):
settings_kv[k] = ','.join(v.get_value()) settings_kv[k] = ','.join(v.get_value())
+3 -1
View File
@@ -19,6 +19,7 @@ __all__ = [
"EngineResults", "EngineResults",
"AnswerSet", "AnswerSet",
"Answer", "Answer",
"AiSummary",
"Translations", "Translations",
"WeatherAnswer", "WeatherAnswer",
"Code", "Code",
@@ -32,7 +33,7 @@ import typing as t
import abc import abc
from ._base import Result, MainResult, LegacyResult from ._base import Result, MainResult, LegacyResult
from .answer import AnswerSet, Answer, Translations, WeatherAnswer from .answer import AnswerSet, Answer, AiSummary, Translations, WeatherAnswer
from .keyvalue import KeyValue from .keyvalue import KeyValue
from .code import Code from .code import Code
from .paper import Paper from .paper import Paper
@@ -49,6 +50,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
implemented).""" implemented)."""
Answer = Answer Answer = Answer
AiSummary = AiSummary
KeyValue = KeyValue KeyValue = KeyValue
Code = Code Code = Code
Paper = Paper Paper = Paper
+27 -1
View File
@@ -14,6 +14,10 @@ template.
:members: :members:
:show-inheritance: :show-inheritance:
.. autoclass:: AiSummary
:members:
:show-inheritance:
.. autoclass:: Translations .. autoclass:: Translations
:members: :members:
:show-inheritance: :show-inheritance:
@@ -29,7 +33,7 @@ template.
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"] __all__ = ["AnswerSet", "Answer", "AiSummary", "Translations", "WeatherAnswer"]
from flask_babel import gettext from flask_babel import gettext
import msgspec import msgspec
@@ -92,6 +96,28 @@ class Answer(BaseAnswer, kw_only=True):
return hash(self.answer) return hash(self.answer)
class AiSummary(BaseAnswer, kw_only=True):
"""Placeholder for an AI generated summary of the search query
(:py:obj:`searx.plugins.ai_summary`). The summary itself is fetched
asynchronously by the client after the result page has been rendered."""
# The answers of an AnswerSet are sorted by their template name; this
# template sorts before the other answer templates, the AI summary is
# therefore rendered first in the answer area.
template: str = "answer/ai_summary.html"
query: str
"""The search query the summary is generated for."""
grounding: bool = False
"""Ground the summary on the search results (user's preference)."""
def __hash__(self):
"""The hash value of field *query* is the hash value of the
:py:obj:`AiSummary` object."""
return hash(self.query)
class Translations(BaseAnswer, kw_only=True): class Translations(BaseAnswer, kw_only=True):
"""Answer type with a list of translations. """Answer type with a list of translations.
+31
View File
@@ -257,6 +257,9 @@ plugins:
searx.plugins.tracker_url_remover.SXNGPlugin: searx.plugins.tracker_url_remover.SXNGPlugin:
active: true active: true
searx.plugins.ai_summary.SXNGPlugin:
active: false
# Configuration of the "Hostnames plugin": # Configuration of the "Hostnames plugin":
# #
@@ -284,6 +287,34 @@ plugins:
# '(.*\.)?youtu\.be$': 'yt.example.com' # '(.*\.)?youtu\.be$': 'yt.example.com'
# #
# Configuration of the "AI Summary plugin", for more details see
# https://docs.searxng.org/admin/settings/settings_ai_summary.html
#
# ai_summary:
#
# # Base URL of an OpenAI compatible LLM server (Ollama, vLLM, LM Studio,
# # llama.cpp, Hugging Face TGI, ...), used as the default for the
# # ai_summary_server preference.
# base_url: "http://127.0.0.1:11434"
#
# # API key of the server above, if it requires authentication (e.g. vLLM or
# # llama.cpp with --api-key). Sent as "Authorization: Bearer" and only to
# # base_url, never to a server configured by a user in the preferences.
# api_key: ""
#
# # Default model; if empty, the first entry of models: is used.
# model: "llama3.2:3b"
#
# # Models suggested to the user in the preferences; if empty, the list
# # is requested from the LLM server (GET /v1/models) at startup.
# models:
# - "llama3.2:3b"
# - "gemma3:4b"
#
# # Ground summaries on the search results by default (users can still opt
# # in/out in their preferences).
# grounding: true
categories_as_tabs: categories_as_tabs:
general: general:
+2
View File
@@ -13,6 +13,7 @@ from os.path import dirname, abspath
import msgspec import msgspec
from typing_extensions import override from typing_extensions import override
from .ai_summary import SettingsAISummary
from .brand import SettingsBrand from .brand import SettingsBrand
from .sxng_locales import sxng_locales from .sxng_locales import sxng_locales
from ._settings import SettingsPref from ._settings import SettingsPref
@@ -267,6 +268,7 @@ SCHEMA: dict[str, t.Any] = {
'networks': {}, 'networks': {},
}, },
'plugins': SettingsValue(dict, {}), 'plugins': SettingsValue(dict, {}),
'ai_summary': SettingsAISummary,
'categories_as_tabs': SettingsValue(dict, CATEGORIES_AS_TABS), 'categories_as_tabs': SettingsValue(dict, CATEGORIES_AS_TABS),
'engines': SettingsValue(list, []), 'engines': SettingsValue(list, []),
'doi_resolvers': {}, 'doi_resolvers': {},
+4
View File
@@ -0,0 +1,4 @@
import{a as e,i as t}from"../sxng-core.min.js";import{t as n}from"./DK4yUVpy.min.js";var r=5,i=12,a=300,o=1e3,s=class s extends e{messages=[];context=[];controller;constructor(){super(`ai_summary`)}async run(){let e=document.getElementById(`ai_summary`);if(e)try{let{query:t}=e.dataset;if(!t)return;e.dataset.grounding===`1`&&(this.context=s.collectContext()),this.wireControls(e),this.messages.push({role:`user`,content:t}),await this.exchange(e)}catch(t){s.showError(e,t)}}async post(){}static collectContext(){let e=[];for(let t of document.querySelectorAll(`#urls article.result`)){if(e.length>=r)break;let n=t.querySelector(`h3 a`);n&&e.push({title:(n.textContent??``).trim().slice(0,a),url:n.href,snippet:(t.querySelector(`.content`)?.textContent??``).trim().slice(0,o)})}return e}wireControls(e){let t=e.querySelector(`.ai-summary-body`),r=e.querySelector(`.ai-summary-more`),a=e.querySelector(`.ai-summary-followup`);n(t),n(r),n(a),r.addEventListener(`click`,()=>{let e=t.classList.toggle(`collapsed`);r.textContent=e?r.dataset.btnTextCollapsed??``:r.dataset.btnTextNotCollapsed??``,a.classList.toggle(`invisible`,e)}),a.addEventListener(`submit`,t=>{t.preventDefault();let r=a.querySelector(`input`);n(r);let o=r.value.trim();if(!o||this.controller)return;r.value=``,this.messages.push({role:`user`,content:o}),this.messages.splice(0,this.messages.length-(i-1));let s=Object.assign(document.createElement(`p`),{textContent:o,className:`ai-summary-question`});e.querySelector(`.ai-summary-answers`)?.append(s),this.exchange(e)})}async exchange(e){let t=e.querySelector(`.ai-summary-answers`);n(t);let r=Object.assign(document.createElement(`p`),{className:`ai-summary-content typing`});t.append(r);let i=new AbortController;this.controller=i;let a=``,o=t=>{if(!t.trim())return;let n=JSON.parse(t);if(n.error)throw Error(n.error);if(n.delta&&(a+=n.delta,r.textContent=a,this.updateMoreButton(e)),n.done&&n.model){let t=e.querySelector(`.ai-summary-model`);t&&(t.textContent=n.model)}};try{let e=await fetch(`./ai_summary`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({messages:this.messages,context:this.context}),signal:i.signal});if(!e.ok)throw Error(`HTTP ${e.status}`);if(e.body){let t=e.body.getReader(),n=new TextDecoder,r=``;for(;;){let{done:e,value:i}=await t.read();if(e)break;r+=n.decode(i,{stream:!0});let a=r.split(`
`);r=a.pop()??``;for(let e of a)o(e)}o(r)}else{let t=await e.text();for(let e of t.split(`
`))o(e)}this.messages.push({role:`assistant`,content:a})}catch(t){s.showError(e,t)}finally{r.classList.remove(`typing`),this.controller=void 0,this.updateMoreButton(e)}}static showError(e,n){console.error(`Error loading AI summary:`,n);let r=t.translations?.error_loading_ai_summary??`Error loading the AI summary`,i=n instanceof Error&&n.message?` (${n.message})`:``,a=Object.assign(document.createElement(`div`),{textContent:`${r}${i}`,className:`dialog-error`});a.setAttribute(`role`,`alert`),(e.querySelector(`.ai-summary-answers`)??e).append(a)}updateMoreButton(e){let t=e.querySelector(`.ai-summary-body`),n=e.querySelector(`.ai-summary-more`);t&&n&&(!t.classList.contains(`collapsed`)||t.scrollHeight>t.clientHeight+4)&&n.classList.remove(`invisible`)}};export{s as default};
//# sourceMappingURL=DBBLNmaq.min.js.map
File diff suppressed because one or more lines are too long
+11
View File
@@ -19,6 +19,7 @@
"src/js/plugin/MapView.ts", "src/js/plugin/MapView.ts",
"src/js/plugin/InfiniteScroll.ts", "src/js/plugin/InfiniteScroll.ts",
"src/js/plugin/Calculator.ts", "src/js/plugin/Calculator.ts",
"src/js/plugin/AiSummary.ts",
"src/js/main/keyboard.ts", "src/js/main/keyboard.ts",
"src/js/main/search.ts", "src/js/main/search.ts",
"src/js/main/autocomplete.ts", "src/js/main/autocomplete.ts",
@@ -76,6 +77,16 @@
"_DcK-mo-Y.min.js" "_DcK-mo-Y.min.js"
] ]
}, },
"src/js/plugin/AiSummary.ts": {
"file": "chunk/DBBLNmaq.min.js",
"name": "aisummary",
"src": "src/js/plugin/AiSummary.ts",
"isDynamicEntry": true,
"imports": [
"src/js/index.ts",
"_DK4yUVpy.min.js"
]
},
"src/js/plugin/Calculator.ts": { "src/js/plugin/Calculator.ts": {
"file": "chunk/C8c7HJzp.min.js", "file": "chunk/C8c7HJzp.min.js",
"name": "calculator", "name": "calculator",
+2 -2
View File
@@ -1,3 +1,3 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./chunk/BnnvKC7b.min.js","./sxng-mapview.min.css","./chunk/DpvWr1cn.min.js","./chunk/DK4yUVpy.min.js","./chunk/DcK-mo-Y.min.js","./chunk/C8c7HJzp.min.js","./chunk/C93hSkpT.min.js","./chunk/5Ako-qGW.min.js","./chunk/DvCYLbJr.min.js","./chunk/od7pNHfk.min.js","./chunk/e2-9fzwE.min.js"])))=>i.map(i=>d[i]); const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./chunk/BnnvKC7b.min.js","./sxng-mapview.min.css","./chunk/DpvWr1cn.min.js","./chunk/DK4yUVpy.min.js","./chunk/DcK-mo-Y.min.js","./chunk/C8c7HJzp.min.js","./chunk/DBBLNmaq.min.js","./chunk/C93hSkpT.min.js","./chunk/5Ako-qGW.min.js","./chunk/DvCYLbJr.min.js","./chunk/od7pNHfk.min.js","./chunk/e2-9fzwE.min.js"])))=>i.map(i=>d[i]);
var e=class{id;constructor(e){this.id=e,queueMicrotask(()=>this.invoke())}async invoke(){try{console.debug(`[PLUGIN] ${this.id}: Running...`);let e=await this.run();if(!e)return;console.debug(`[PLUGIN] ${this.id}: Running post-exec...`),await this.post(e)}catch(e){console.error(`[PLUGIN] ${this.id}:`,e)}finally{console.debug(`[PLUGIN] ${this.id}: Done.`)}}},t={index:`index`,results:`results`,preferences:`preferences`,unknown:`unknown`},n={closeDetail:void 0,scrollPageToSelected:void 0,selectImage:void 0,selectNext:void 0,selectPrevious:void 0},r=()=>{let e=document.querySelector(`meta[name="endpoint"]`)?.getAttribute(`content`);return e&&e in t?e:t.unknown},i=()=>{let e=document.querySelector(`script[client_settings]`)?.getAttribute(`client_settings`);if(!e)return{};try{return JSON.parse(atob(e))}catch(e){return console.error(`Failed to load client_settings:`,e),{}}},a=async(e,t,n)=>{let r=new AbortController,i=setTimeout(()=>r.abort(),n?.timeout??3e4),a=await fetch(t,{body:n?.body,method:e,signal:r.signal}).finally(()=>clearTimeout(i));if(!a.ok)throw Error(a.statusText);return a},o=(e,t,n,r)=>{if(typeof t!=`string`){t.addEventListener(e,n,r);return}document.addEventListener(e,e=>{for(let r of e.composedPath())if(r instanceof HTMLElement&&r.matches(t)){try{n.call(r,e)}catch(e){console.error(e)}break}},r)},s=(e,t)=>{for(let e of t?.on??[])if(!e)return;document.readyState===`loading`?o(`DOMContentLoaded`,document,e,{once:!0}):e()},c=r(),l=i(),u=(e,t)=>{d(t)&&e()},d=e=>{switch(e.on){case`global`:return!0;case`endpoint`:return!!e.where.includes(c)}},f=`modulepreload`,p=function(e,t){return new URL(e,t).href},m={},h=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=p(t,n),t=s(t),t in m)return;m[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:f,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})};s(()=>{document.documentElement.classList.remove(`no-js`),document.documentElement.classList.add(`js`),o(`click`,`.close`,function(){this.parentNode?.classList.add(`invisible`)}),o(`click`,`.searxng_init_map`,async function(e){e.preventDefault(),this.classList.remove(`searxng_init_map`),u(()=>h(async()=>{let{default:e}=await import(`./chunk/BnnvKC7b.min.js`);return{default:e}},__vite__mapDeps([0,1]),import.meta.url).then(({default:e})=>new e(this)),{on:`endpoint`,where:[t.results]})}),l.plugins?.includes(`infiniteScroll`)&&u(()=>h(async()=>{let{default:e}=await import(`./chunk/DpvWr1cn.min.js`);return{default:e}},__vite__mapDeps([2,3,4]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[t.results]}),l.plugins?.includes(`calculator`)&&u(()=>h(async()=>{let{default:e}=await import(`./chunk/C8c7HJzp.min.js`);return{default:e}},__vite__mapDeps([5,4,3]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[t.results]})}),s(()=>{h(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([6,3]),import.meta.url),h(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([7,4,3]),import.meta.url),l.autocomplete&&h(()=>import(`./chunk/DvCYLbJr.min.js`),__vite__mapDeps([8,3]),import.meta.url)},{on:[c===t.index]}),s(()=>{h(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([6,3]),import.meta.url),h(()=>import(`./chunk/od7pNHfk.min.js`),__vite__mapDeps([9,3]),import.meta.url),h(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([7,4,3]),import.meta.url),l.autocomplete&&h(()=>import(`./chunk/DvCYLbJr.min.js`),__vite__mapDeps([8,3]),import.meta.url)},{on:[c===t.results]}),s(()=>{h(()=>import(`./chunk/e2-9fzwE.min.js`),__vite__mapDeps([10,3]),import.meta.url)},{on:[c===t.preferences]});export{e as a,l as i,o as n,n as r,a as t}; var e=class{id;constructor(e){this.id=e,queueMicrotask(()=>this.invoke())}async invoke(){try{console.debug(`[PLUGIN] ${this.id}: Running...`);let e=await this.run();if(!e)return;console.debug(`[PLUGIN] ${this.id}: Running post-exec...`),await this.post(e)}catch(e){console.error(`[PLUGIN] ${this.id}:`,e)}finally{console.debug(`[PLUGIN] ${this.id}: Done.`)}}},t={index:`index`,results:`results`,preferences:`preferences`,unknown:`unknown`},n={closeDetail:void 0,scrollPageToSelected:void 0,selectImage:void 0,selectNext:void 0,selectPrevious:void 0},r=()=>{let e=document.querySelector(`meta[name="endpoint"]`)?.getAttribute(`content`);return e&&e in t?e:t.unknown},i=()=>{let e=document.querySelector(`script[client_settings]`)?.getAttribute(`client_settings`);if(!e)return{};try{return JSON.parse(atob(e))}catch(e){return console.error(`Failed to load client_settings:`,e),{}}},a=async(e,t,n)=>{let r=new AbortController,i=setTimeout(()=>r.abort(),n?.timeout??3e4),a=await fetch(t,{body:n?.body,method:e,signal:r.signal}).finally(()=>clearTimeout(i));if(!a.ok)throw Error(a.statusText);return a},o=(e,t,n,r)=>{if(typeof t!=`string`){t.addEventListener(e,n,r);return}document.addEventListener(e,e=>{for(let r of e.composedPath())if(r instanceof HTMLElement&&r.matches(t)){try{n.call(r,e)}catch(e){console.error(e)}break}},r)},s=(e,t)=>{for(let e of t?.on??[])if(!e)return;document.readyState===`loading`?o(`DOMContentLoaded`,document,e,{once:!0}):e()},c=r(),l=i(),u=(e,t)=>{d(t)&&e()},d=e=>{switch(e.on){case`global`:return!0;case`endpoint`:return!!e.where.includes(c)}},f=`modulepreload`,p=function(e,t){return new URL(e,t).href},m={},h=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=p(t,n),t=s(t),t in m)return;m[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:f,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})};s(()=>{document.documentElement.classList.remove(`no-js`),document.documentElement.classList.add(`js`),o(`click`,`.close`,function(){this.parentNode?.classList.add(`invisible`)}),o(`click`,`.searxng_init_map`,async function(e){e.preventDefault(),this.classList.remove(`searxng_init_map`),u(()=>h(async()=>{let{default:e}=await import(`./chunk/BnnvKC7b.min.js`);return{default:e}},__vite__mapDeps([0,1]),import.meta.url).then(({default:e})=>new e(this)),{on:`endpoint`,where:[t.results]})}),l.plugins?.includes(`infiniteScroll`)&&u(()=>h(async()=>{let{default:e}=await import(`./chunk/DpvWr1cn.min.js`);return{default:e}},__vite__mapDeps([2,3,4]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[t.results]}),l.plugins?.includes(`calculator`)&&u(()=>h(async()=>{let{default:e}=await import(`./chunk/C8c7HJzp.min.js`);return{default:e}},__vite__mapDeps([5,4,3]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[t.results]}),l.plugins?.includes(`ai_summary`)&&u(()=>h(async()=>{let{default:e}=await import(`./chunk/DBBLNmaq.min.js`);return{default:e}},__vite__mapDeps([6,3]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[t.results]})}),s(()=>{h(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([7,3]),import.meta.url),h(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([8,4,3]),import.meta.url),l.autocomplete&&h(()=>import(`./chunk/DvCYLbJr.min.js`),__vite__mapDeps([9,3]),import.meta.url)},{on:[c===t.index]}),s(()=>{h(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([7,3]),import.meta.url),h(()=>import(`./chunk/od7pNHfk.min.js`),__vite__mapDeps([10,3]),import.meta.url),h(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([8,4,3]),import.meta.url),l.autocomplete&&h(()=>import(`./chunk/DvCYLbJr.min.js`),__vite__mapDeps([9,3]),import.meta.url)},{on:[c===t.results]}),s(()=>{h(()=>import(`./chunk/e2-9fzwE.min.js`),__vite__mapDeps([11,3]),import.meta.url)},{on:[c===t.preferences]});export{e as a,l as i,o as n,n as r,a as t};
//# sourceMappingURL=sxng-core.min.js.map //# sourceMappingURL=sxng-core.min.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
<div id="ai_summary" class="ai-summary hide_if_nojs" {{- ' ' -}}
data-query="{{ answer.query }}" {{- ' ' -}}
data-grounding="{{ '1' if answer.grounding else '0' }}">{{- '' -}}
<div class="ai-summary-header">{{- '' -}}
<span class="ai-summary-title">{{ _('AI Summary') }}</span>{{- '' -}}
<span class="ai-summary-model"></span>{{- '' -}}
</div>{{- '' -}}
<div class="ai-summary-body collapsed">{{- '' -}}
<div class="ai-summary-answers" aria-live="polite"></div>{{- '' -}}
</div>{{- '' -}}
<button type="button" class="ai-summary-more invisible" {{- ' ' -}}
data-btn-text-collapsed="{{ _('More') }}" {{- ' ' -}}
data-btn-text-not-collapsed="{{ _('Less') }}">{{ _('More') }}</button>{{- '' -}}
<form class="ai-summary-followup invisible">{{- '' -}}
<input type="text" placeholder="{{ _('Ask a follow-up question') }}" autocomplete="off" maxlength="2048">{{- '' -}}
<button type="submit">{{ _('Ask') }}</button>{{- '' -}}
</form>{{- '' -}}
<p class="ai-summary-disclaimer">{{ _('Generated by AI — may contain mistakes.') }}</p>{{- '' -}}
</div>
+12
View File
@@ -249,6 +249,18 @@
{%- endif -%} {%- endif -%}
{{- tab_footer() -}} {{- tab_footer() -}}
{# tab: ai #}
{#- the tab is only shown when the administrator activated the plugin in
settings.yml, an instance without an AI summary has no AI tab -#}
{%- if 'ai_summary' in plugins_active_by_default
and plugins_storage | selectattr('preference_section', 'equalto', 'ai') | list -%}
{{- tab_header('maintab', 'ai', _('AI Summary')) -}}
{{- plugin_preferences('ai') -}}
{%- include 'simple/preferences/ai_summary.html' -%}
{{- tab_footer() -}}
{%- endif -%}
{# tab: cookies #} {# tab: cookies #}
{{- tab_header('maintab', 'cookies', _('Cookies')) -}} {{- tab_header('maintab', 'cookies', _('Cookies')) -}}
@@ -0,0 +1,72 @@
{%- if 'ai_summary_server' not in locked_preferences -%}
<fieldset>{{- '' -}}
<legend id="pref_ai_summary_server">{{- _('AI server URL') -}}</legend>{{- '' -}}
<div class="value">{{- '' -}}
<input name="ai_summary_server" aria-labelledby="pref_ai_summary_server" type="text"
autocomplete="off" spellcheck="false" autocorrect="off"
placeholder="{{ ai_summary_default_server or 'http://127.0.0.1:11434' }}"
value="{{ preferences.get_value('ai_summary_server') }}">{{- '' -}}
</div>{{- '' -}}
<div class="description">
{{- _('URL of the OpenAI compatible LLM server that generates the summaries (e.g. Ollama, LM Studio, vLLM), e.g. http://192.168.1.10:11434.') -}}
{{- ' ' -}}
{%- if ai_summary_default_server -%}
{{- _('Leave empty to use the default of this instance.') -}}
{%- endif -%}
</div>{{- '' -}}
</fieldset>{{- '' -}}
{%- endif -%}
{%- if 'ai_summary_api_key' not in locked_preferences -%}
<fieldset>{{- '' -}}
<legend id="pref_ai_summary_api_key">{{- _('AI server API key') -}}</legend>{{- '' -}}
<div class="value">{{- '' -}}
<input name="ai_summary_api_key" aria-labelledby="pref_ai_summary_api_key" type="password"
autocomplete="off" spellcheck="false" autocorrect="off"
value="{{ preferences.get_value('ai_summary_api_key') }}">{{- '' -}}
</div>{{- '' -}}
<div class="description">
{{- _('Optional, only needed if your server requires authentication.') -}}
</div>{{- '' -}}
</fieldset>{{- '' -}}
{%- endif -%}
{%- if 'ai_summary_model' not in locked_preferences -%}
<fieldset>{{- '' -}}
<legend id="pref_ai_summary_model">{{- _('AI summary model') -}}</legend>{{- '' -}}
<div class="value">{{- '' -}}
<input name="ai_summary_model" aria-labelledby="pref_ai_summary_model" type="text"
autocomplete="off" spellcheck="false" autocorrect="off" list="ai_summary_model_list"
placeholder="{{ ai_summary_default_model or 'llama3.2:3b' }}"
value="{{ preferences.get_value('ai_summary_model') }}">{{- '' -}}
<datalist id="ai_summary_model_list">
{%- for model in ai_summary_models -%}
<option value="{{ model }}"></option>
{%- endfor -%}
</datalist>{{- '' -}}
</div>{{- '' -}}
<div class="description">
{{- _('Name of the model used to generate the summaries, e.g. llama3.2:3b or gemma3:4b.') -}}
{{- ' ' -}}
{%- if ai_summary_default_model -%}
{{- _('Leave empty to use the default of this instance.') -}}
{%- endif -%}
</div>{{- '' -}}
</fieldset>{{- '' -}}
{%- endif -%}
{%- if 'ai_summary_grounding' not in locked_preferences -%}
<fieldset>{{- '' -}}
<legend id="pref_ai_summary_grounding">{{ _('Ground AI summary on search results') }}</legend>{{- '' -}}
<p class="value">{{- '' -}}
<input type="checkbox" {{- ' ' -}}
name="ai_summary_grounding" {{- ' ' -}}
aria-labelledby="pref_ai_summary_grounding" {{- ' ' -}}
class="checkbox-onoff" {{- ' ' -}}
{%- if preferences.get_value('ai_summary_grounding') -%}
checked
{%- endif -%}{{- ' ' -}}
>{{- '' -}}
</p>{{- '' -}}
<div class="description">
{{- _('Send the top search results along with the query, the model answers from this context instead of its own knowledge. Answers are more accurate and more current, but generating them is slower and needs more memory (VRAM) on the Ollama server.') -}}
</div>{{- '' -}}
</fieldset>{{- '' -}}
{%- endif -%}
+13
View File
@@ -93,8 +93,10 @@ from searx.preferences import (
ClientPref, ClientPref,
ValidationException, ValidationException,
) )
import searx.ai_summary
import searx.answerers import searx.answerers
import searx.plugins import searx.plugins
import searx.plugins.ai_summary
from searx.metrics import get_engines_stats, get_engine_errors, get_reliabilities, histogram, counter, openmetrics from searx.metrics import get_engines_stats, get_engine_errors, get_reliabilities, histogram, counter, openmetrics
@@ -328,6 +330,8 @@ def get_translations():
'Source': gettext('Source'), 'Source': gettext('Source'),
# infinite scroll # infinite scroll
'error_loading_next_page': gettext('Error loading the next page'), 'error_loading_next_page': gettext('Error loading the next page'),
# AI summary
'error_loading_ai_summary': gettext('Error loading the AI summary'),
} }
@@ -976,16 +980,25 @@ def preferences():
shortcuts = {y: x for x, y in engine_shortcuts.items()}, shortcuts = {y: x for x, y in engine_shortcuts.items()},
themes = themes, themes = themes,
plugins_storage = searx.plugins.STORAGE.info, plugins_storage = searx.plugins.STORAGE.info,
# plugins the administrator activated in settings.yml; a plugin that is
# not activated does not get a preferences tab of its own
plugins_active_by_default = {plg.id for plg in searx.plugins.STORAGE if plg.active},
current_doi_resolver = get_doi_resolver(), current_doi_resolver = get_doi_resolver(),
allowed_plugins = allowed_plugins, allowed_plugins = allowed_plugins,
preferences_url_params = sxng_request.preferences.get_as_url_params(), preferences_url_params = sxng_request.preferences.get_as_url_params(),
locked_preferences = get_setting("preferences").lock, locked_preferences = get_setting("preferences").lock,
doi_resolvers = get_setting("doi_resolvers", {}), doi_resolvers = get_setting("doi_resolvers", {}),
ai_summary_models = searx.ai_summary.model_choices(),
ai_summary_default_model = get_setting("ai_summary").model,
ai_summary_default_server = get_setting("ai_summary").base_url,
# fmt: on # fmt: on
) )
app.add_url_rule('/favicon_proxy', methods=['GET'], endpoint="favicon_proxy", view_func=favicons.favicon_proxy) app.add_url_rule('/favicon_proxy', methods=['GET'], endpoint="favicon_proxy", view_func=favicons.favicon_proxy)
app.add_url_rule(
'/ai_summary', methods=['POST'], endpoint="ai_summary", view_func=searx.plugins.ai_summary.ai_summary_view
)
@app.route('/image_proxy', methods=['GET']) @app.route('/image_proxy', methods=['GET'])
+563
View File
@@ -0,0 +1,563 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=missing-module-docstring,missing-class-docstring,invalid-name,protected-access
# pylint: disable=too-many-public-methods
import json
from base64 import urlsafe_b64decode
from contextlib import contextmanager
from zlib import decompress
from mock import Mock
import searx.ai_summary
import searx.plugins
import searx.plugins.ai_summary
import searx.preferences
from searx.extended_types import sxng_request
from searx.result_types import AiSummary
from tests import SearxTestCase
from .test_plugins import get_search_mock
PLUGIN_FQN = "searx.plugins.ai_summary.SXNGPlugin"
BASE_URL = "http://127.0.0.1:11434"
MODEL = "test-model"
def sse_stream_mock(lines: list[dict], status_code: int = 200) -> Mock:
"""A mock httpx client whose ``stream()`` context manager yields the given
objects as a SSE stream (OpenAI chat completions format)."""
upstream = Mock(status_code=status_code)
upstream.iter_lines.return_value = iter([f"data: {json.dumps(line)}" for line in lines] + ["data: [DONE]"])
# httpx exposes the body of an error response as a str; the endpoint logs it
upstream.text = ""
@contextmanager
def stream(*_args, **_kwargs):
yield upstream
client = Mock()
client.stream = stream
return client
class AISummaryAPIKey(SearxTestCase):
"""The API key is administrator configuration and must only be sent to the
administrator's server, never to a server a user configured."""
def setUp(self):
super().setUp()
self.cfg = searx.get_setting("ai_summary")
self.setattr4test(self.cfg, "base_url", BASE_URL)
self.setattr4test(self.cfg, "api_key", "sk-secret")
def test_auth_header_set_for_api_key(self):
with searx.plugins.ai_summary._get_client(BASE_URL, self.cfg, "sk-secret") as client:
self.assertEqual(client.headers["Authorization"], "Bearer sk-secret")
def test_no_auth_header_without_api_key(self):
with searx.plugins.ai_summary._get_client(BASE_URL, self.cfg) as client:
self.assertNotIn("Authorization", client.headers)
def test_key_sent_to_admin_server(self):
for server in [BASE_URL, BASE_URL + "/", BASE_URL + "/v1", "http://127.0.0.1:11434/v1/"]:
self.assertEqual("sk-secret", searx.plugins.ai_summary._server_api_key(self.cfg, server), server)
def test_key_not_sent_to_other_server(self):
for server in [
"http://192.168.1.10:11434", # other host
"http://127.0.0.1:8080", # other port
"https://127.0.0.1:11434", # other scheme
"http://127.0.0.1:11434/other", # other path
# the userinfo of a URL must not be mistaken for the host the
# request is sent to
"http://127.0.0.1:11434@untrusted.example.org",
"not a url",
"",
]:
self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, server), server)
def test_no_key_configured(self):
self.setattr4test(self.cfg, "api_key", "")
self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL))
def test_user_key_goes_to_the_users_own_server(self):
key = searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", "sk-users-own")
self.assertEqual("sk-users-own", key)
def test_user_key_does_not_override_the_admin_key(self):
# the user's key belongs to the user's server; on the admin's server
# the admin's key is the right one
key = searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL, "sk-users-own")
self.assertEqual("sk-secret", key)
def test_no_key_for_a_user_server_without_a_user_key(self):
self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", ""))
class PluginAISummaryInit(SearxTestCase):
def test_active_without_base_url(self):
# the Ollama server can be configured by the user in the preferences,
# the plugin stays active without an admin configured default
self.setattr4test(searx.ai_summary, "MODELS", [])
storage = searx.plugins.PluginStorage()
storage.load_settings({PLUGIN_FQN: {"active": True}})
storage.init(self.app)
self.assertEqual(1, len(storage))
self.assertEqual([], searx.ai_summary.MODELS)
def test_model_list_from_settings(self):
cfg = searx.get_setting("ai_summary")
self.setattr4test(cfg, "base_url", BASE_URL)
self.setattr4test(cfg, "model", MODEL)
self.setattr4test(cfg, "models", [MODEL, "other-model"])
self.setattr4test(searx.ai_summary, "MODELS", [])
storage = searx.plugins.PluginStorage()
storage.load_settings({PLUGIN_FQN: {"active": True}})
storage.init(self.app)
self.assertEqual(1, len(storage))
self.assertEqual([MODEL, "other-model"], searx.ai_summary.MODELS)
def test_model_probe_sends_api_key(self):
cfg = searx.get_setting("ai_summary")
self.setattr4test(cfg, "base_url", BASE_URL)
self.setattr4test(cfg, "model", "")
self.setattr4test(cfg, "models", [])
self.setattr4test(cfg, "api_key", "sk-secret")
self.setattr4test(searx.ai_summary, "MODELS", [])
calls: list[tuple[str, str]] = []
class _FakeClient:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def get(self, _path):
resp = Mock()
resp.json.return_value = {"data": [{"id": "probed-model"}]}
return resp
def record(base_url, _cfg, api_key=""):
calls.append((base_url, api_key))
return _FakeClient()
self.setattr4test(searx.plugins.ai_summary, "_get_client", record)
storage = searx.plugins.PluginStorage()
storage.load_settings({PLUGIN_FQN: {"active": True}})
storage.init(self.app)
self.assertEqual([(BASE_URL, "sk-secret")], calls)
self.assertEqual(["probed-model"], searx.ai_summary.MODELS)
class PluginAISummary(SearxTestCase):
def setUp(self):
super().setUp()
cfg = searx.get_setting("ai_summary")
self.setattr4test(cfg, "base_url", BASE_URL)
self.setattr4test(cfg, "model", MODEL)
self.setattr4test(cfg, "models", [MODEL])
self.setattr4test(searx.ai_summary, "MODELS", [])
self.storage = searx.plugins.PluginStorage()
self.storage.load_settings({PLUGIN_FQN: {"active": True}})
self.storage.init(self.app)
# the endpoint checks request.user_plugins (built in webapp.pre_request
# from the global plugin storage) -- enable the plugin like a browser
# with saved preferences does
self.client.set_cookie("disabled_plugins", "")
self.client.set_cookie("enabled_plugins", "ai_summary")
self.pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
self.pref.parse_dict({"locale": "en"})
def mock_upstream(self, client_mock: Mock):
self.setattr4test(searx.plugins.ai_summary, "_get_client", lambda *_args, **_kwargs: client_mock)
def mock_upstream_recording(self, client_mock: Mock) -> list[tuple[str, str]]:
"""Like :py:obj:`mock_upstream`, the returned list records the
``(base_url, api_key)`` the endpoint requested a client for."""
calls: list[tuple[str, str]] = []
def record(base_url, _cfg, api_key=""):
calls.append((base_url, api_key))
return client_mock
self.setattr4test(searx.plugins.ai_summary, "_get_client", record)
return calls
def do_post_search(self, query, **kwargs) -> Mock:
kwargs.setdefault("categories", ["general"])
search = get_search_mock(query, user_plugins=["ai_summary"], **kwargs)
self.storage.post_search(sxng_request, search)
return search
def test_placeholder_answer_is_added(self):
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = self.do_post_search("what is the best searx fork")
answer = AiSummary(query="what is the best searx fork", grounding=False)
self.assertIn(answer, search.result_container.answers)
def test_placeholder_carries_grounding_pref(self):
with self.app.test_request_context():
sxng_request.preferences = self.pref
self.pref.parse_dict({"ai_summary_grounding": "1"})
search = self.do_post_search("lorem ipsum")
answer = list(search.result_container.answers)[0]
self.assertTrue(answer.grounding)
def test_grounding_is_on_by_default(self):
# note: AiSummary.__hash__ is hash(query), so two answers that differ
# only in .grounding compare equal -- assert on the attribute
self.assertTrue(searx.get_setting("ai_summary").grounding)
pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
with self.app.test_request_context():
sxng_request.preferences = pref
search = self.do_post_search("lorem ipsum")
answer = list(search.result_container.answers)[0]
self.assertTrue(answer.grounding)
def test_grounding_can_be_disabled_by_settings(self):
self.setattr4test(searx.get_setting("ai_summary"), "grounding", False)
pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage)
with self.app.test_request_context():
sxng_request.preferences = pref
search = self.do_post_search("lorem ipsum")
answer = list(search.result_container.answers)[0]
self.assertFalse(answer.grounding)
def test_skip_pageno(self):
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = self.do_post_search("lorem ipsum", pageno=2)
self.assertEqual(list(search.result_container.answers), [])
def test_skip_non_html_format(self):
with self.app.test_request_context("/search", method="POST", data={"format": "json"}):
sxng_request.preferences = self.pref
search = self.do_post_search("lorem ipsum")
self.assertEqual(list(search.result_container.answers), [])
def test_skip_non_general_category(self):
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = self.do_post_search("lorem ipsum", categories=["images"])
self.assertEqual(list(search.result_container.answers), [])
def test_skip_infobox(self):
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = get_search_mock("lorem ipsum", user_plugins=["ai_summary"], categories=["general"])
search.result_container.infoboxes.append(Mock())
self.storage.post_search(sxng_request, search)
self.assertEqual(list(search.result_container.answers), [])
def test_skip_instant_answer(self):
# e.g. the "ddg definitions" engine adds wikipedia abstracts as Answer
from searx.result_types import Answer # pylint: disable=import-outside-toplevel
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = get_search_mock("lorem ipsum", user_plugins=["ai_summary"], categories=["general"])
engine_answer = Answer(answer="Lorem ipsum is placeholder text. More at Wikipedia")
search.result_container.answers.add(engine_answer)
self.storage.post_search(sxng_request, search)
self.assertEqual(list(search.result_container.answers), [engine_answer])
def test_skip_without_any_server(self):
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
with self.app.test_request_context():
sxng_request.preferences = self.pref
search = self.do_post_search("lorem ipsum")
self.assertEqual(list(search.result_container.answers), [])
def test_placeholder_with_user_server_only(self):
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
with self.app.test_request_context():
sxng_request.preferences = self.pref
self.pref.parse_dict({"ai_summary_server": "http://192.168.1.10:11434"})
search = self.do_post_search("lorem ipsum")
self.assertEqual(len(list(search.result_container.answers)), 1)
def test_endpoint_forbidden_when_disabled(self):
self.client.set_cookie("disabled_plugins", "ai_summary")
self.client.set_cookie("enabled_plugins", "")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 403)
def test_endpoint_bad_request(self):
for body in [
None,
{},
{"messages": []},
{"messages": [{"role": "system", "content": "hi"}]},
{"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "ho"}]},
{"messages": [{"role": "user", "content": "x" * 5000}]},
{"messages": [{"role": "user", "content": "hi"}], "context": [{"unknown_key": "x"}]},
{"messages": [{"role": "user", "content": "hi"}] * 13},
]:
res = self.client.post("/ai_summary", json=body)
self.assertEqual(res.status_code, 400, body)
def test_endpoint_invalid_server_pref(self):
self.client.set_cookie("ai_summary_server", "ftp://example.org")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 400)
def test_endpoint_invalid_model_pref(self):
# an empty preference is not invalid -- it falls back to the
# administrator's default, which is covered by the tests above
for model in ["bad model name!", "model;rm -rf", 'model"quoted', "model\nname", "x" * 129]:
self.client.set_cookie("ai_summary_model", model)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 400, model)
def test_endpoint_accepts_provider_model_names(self):
# model names differ per provider: a version pin (@), a gateway's
# routing prefix (/) and an Ollama tag (:) are all valid names
for model in [
"gemma3:4b",
"llama3.2:3b",
"gemini-1.5-pro@001",
"bedrock/anthropic.claude-3-5-sonnet",
"azure/my-deployment",
]:
self.mock_upstream(sse_stream_mock([]))
self.client.set_cookie("ai_summary_model", model)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200, model)
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[-1], {"done": True, "model": model})
def test_endpoint_no_server_configured(self):
self.setattr4test(searx.get_setting("ai_summary"), "base_url", "")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 400)
def test_endpoint_streams_ndjson(self):
self.mock_upstream(
sse_stream_mock(
[
{"choices": [{"delta": {"content": "Hello "}}]},
{"choices": [{"delta": {"content": "world"}}]},
{"choices": [{"delta": {}, "finish_reason": "stop"}]},
]
)
)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers["Content-Type"], "application/x-ndjson")
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[0], {"delta": "Hello "})
self.assertEqual(lines[1], {"delta": "world"})
self.assertEqual(lines[2], {"done": True, "model": MODEL})
def test_endpoint_model_pref_wins(self):
self.mock_upstream(sse_stream_mock([]))
self.client.set_cookie("ai_summary_model", "my-own-model:7b")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[-1], {"done": True, "model": "my-own-model:7b"})
def test_endpoint_sends_api_key_to_admin_server(self):
self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret")
calls = self.mock_upstream_recording(sse_stream_mock([]))
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual([(BASE_URL, "sk-secret")], calls)
def test_endpoint_hides_api_key_from_user_server(self):
self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret")
calls = self.mock_upstream_recording(sse_stream_mock([]))
self.client.set_cookie("ai_summary_server", "http://untrusted.example.org:11434")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual([("http://untrusted.example.org:11434", "")], calls)
def test_endpoint_ignores_credentials_in_user_server(self):
# httpx would turn the userinfo into an Authorization header; the
# preference is ignored and the admin's server is used instead
self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret")
calls = self.mock_upstream_recording(sse_stream_mock([]))
self.client.set_cookie("ai_summary_server", "http://user:pass@untrusted.example.org:11434")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual([(BASE_URL, "sk-secret")], calls)
def test_endpoint_sends_the_users_key_to_the_users_server(self):
self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret")
calls = self.mock_upstream_recording(sse_stream_mock([]))
self.client.set_cookie("ai_summary_server", "http://192.168.1.10:11434")
self.client.set_cookie("ai_summary_api_key", "sk-users-own")
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual([("http://192.168.1.10:11434", "sk-users-own")], calls)
def test_api_key_is_not_part_of_the_preferences_url(self):
# users copy the preferences URL around to transfer/share their
# settings -- a credential must not travel with it
self.pref.parse_dict({"ai_summary_api_key": "sk-users-own"})
self.assertEqual("sk-users-own", self.pref.get_value("ai_summary_api_key"))
blob = self.pref.get_as_url_params()
decoded = decompress(urlsafe_b64decode(blob)).decode()
self.assertNotIn("sk-users-own", decoded)
self.assertNotIn("ai_summary_api_key", decoded)
# a non-secret preference of the same tab is still included
self.assertIn("ai_summary_model", decoded)
def test_preferences_tab_hidden_when_plugin_not_activated(self):
# the global STORAGE is what the preferences view renders from; in the
# default settings the ai_summary plugin is not activated
res = self.client.get("/preferences")
self.assertEqual(res.status_code, 200)
self.assertNotIn('tab-label-ai"', res.data.decode())
def test_preferences_tab_shown_when_plugin_activated(self):
self.setattr4test(searx.plugins, "STORAGE", self.storage)
res = self.client.get("/preferences")
self.assertEqual(res.status_code, 200)
html = res.data.decode()
self.assertIn('tab-label-ai"', html)
self.assertIn("ai_summary_api_key", html)
def test_endpoint_retries_a_server_that_does_not_answer(self):
# a cold LLM server loads the model before it answers anything, which
# can outlast read_timeout; the retry is what makes the first search
# after an idle period work
import httpx # pylint: disable=import-outside-toplevel
attempts = []
ok = sse_stream_mock([{"choices": [{"delta": {"content": "hi"}}]}])
@contextmanager
def stream(*_args, **_kwargs):
attempts.append(1)
if len(attempts) == 1:
raise httpx.ReadTimeout("timed out")
with ok.stream() as resp:
yield resp
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual(2, len(attempts))
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[0], {"delta": "hi"})
def test_endpoint_gives_up_after_the_retry(self):
import httpx # pylint: disable=import-outside-toplevel
attempts = []
@contextmanager
def stream(*_args, **_kwargs):
attempts.append(1)
raise httpx.ConnectTimeout("nope")
yield # pylint: disable=unreachable
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 502)
self.assertEqual(2, len(attempts))
def test_endpoint_does_not_retry_a_client_error(self):
# a wrong API key or an unknown model does not fix itself
attempts = []
bad = sse_stream_mock([], status_code=401)
@contextmanager
def stream(*_args, **_kwargs):
attempts.append(1)
with bad.stream() as resp:
yield resp
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 502)
self.assertEqual(1, len(attempts))
def test_endpoint_retries_a_server_error(self):
# 5xx means "not right now" -- e.g. a model that is still loading;
# this is the failure that arrives immediately instead of timing out
attempts = []
ok = sse_stream_mock([{"choices": [{"delta": {"content": "hi"}}]}])
bad = sse_stream_mock([], status_code=503)
@contextmanager
def stream(*_args, **_kwargs):
attempts.append(1)
src = bad if len(attempts) == 1 else ok
with src.stream() as resp:
yield resp
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual(2, len(attempts))
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[0], {"delta": "hi"})
def test_endpoint_upstream_error(self):
self.mock_upstream(sse_stream_mock([], status_code=500))
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 502)
def test_endpoint_error_while_streaming(self):
upstream = Mock(status_code=200)
upstream.iter_lines.return_value = iter(
['data: {"choices": [{"delta": {"content": "Hello"}}]}', "data: this is not json"]
)
@contextmanager
def stream(*_args, **_kwargs):
yield upstream
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[0], {"delta": "Hello"})
self.assertEqual(lines[1], {"done": True, "error": "upstream error"})
+1 -1
View File
@@ -169,7 +169,7 @@ class TestPreferences(SearxTestCase):
self.preferences.parse_encoded_data(url_params) self.preferences.parse_encoded_data(url_params)
self.assertEqual( self.assertEqual(
vars(self.preferences.key_value_settings['categories']), vars(self.preferences.key_value_settings['categories']),
{'value': ['general'], 'locked': False, 'choices': ['general', 'none']}, {'value': ['general'], 'locked': False, 'secret': False, 'choices': ['general', 'none']},
) )
def test_save_key_value_setting(self): def test_save_key_value_setting(self):