[feat] plugin: AI summary of search results from a self-hosted LLM

Adds an optional plugin that shows a generated summary above the search
results, similar to the answer boxes in Brave and Google.  The text is
produced by an LLM server the administrator runs, reached over the
OpenAI chat completions API, so queries never leave the operator's own
network.

The summary is grounded on the top search results rather than the
model's training data.  It is generated asynchronously: post_search adds
an empty placeholder and returns, and the browser fills it from the
/ai_summary endpoint, which streams the answer as NDJSON.  No summary is
generated beyond page one, outside the general category, for non-HTML
formats, or when an engine already answered with an infobox or an
instant answer.

The client side follows the existing plugin pattern: one file in
client/simple/src/js/plugin/, one conditional load in router.ts, one
LESS import.  No build configuration changes and no new dependencies.

The plugin is not activated by default.  Instance defaults live in an
ai_summary: section; the server, model, API key and grounding are user
preferences, and all four can be locked.

Signed-off-by: Jason Witty <jasonpwitty+github@proton.me>
This commit is contained in:
Jason Witty
2026-08-15 19:55:07 -07:00
committed by jasonwitty
parent 094c33d406
commit f4a003d355
43 changed files with 1951 additions and 54 deletions
+3
View File
@@ -33,6 +33,9 @@ class SettingsPref(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
"theme",
"results_on_new_tab",
"doi_resolver",
"ai_summary_server",
"ai_summary_model",
"ai_summary_grounding",
"simple_style",
"center_alignment",
"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
"""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
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:
"""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__()
self.value: t.Any = default_value
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):
"""Parse ``data`` and store the result at ``self.value``
@@ -462,6 +466,27 @@ class Preferences:
locked="doi_resolver" in self.cfg.lock,
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(
get_setting("ui.theme_args.simple_style"),
locked="simple_style" in self.cfg.lock,
@@ -498,7 +523,7 @@ class Preferences:
"""Return preferences as URL parameters"""
settings_kv = {}
for k, v in self.key_value_settings.items():
if v.locked:
if v.locked or v.secret:
continue
if isinstance(v, MultipleChoiceSetting):
settings_kv[k] = ','.join(v.get_value())
+3 -1
View File
@@ -19,6 +19,7 @@ __all__ = [
"EngineResults",
"AnswerSet",
"Answer",
"AiSummary",
"Translations",
"WeatherAnswer",
"Code",
@@ -32,7 +33,7 @@ import typing as t
import abc
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 .code import Code
from .paper import Paper
@@ -49,6 +50,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
implemented)."""
Answer = Answer
AiSummary = AiSummary
KeyValue = KeyValue
Code = Code
Paper = Paper
+27 -1
View File
@@ -14,6 +14,10 @@ template.
:members:
:show-inheritance:
.. autoclass:: AiSummary
:members:
:show-inheritance:
.. autoclass:: Translations
:members:
:show-inheritance:
@@ -29,7 +33,7 @@ template.
# pylint: disable=too-few-public-methods
__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"]
__all__ = ["AnswerSet", "Answer", "AiSummary", "Translations", "WeatherAnswer"]
from flask_babel import gettext
import msgspec
@@ -92,6 +96,28 @@ class Answer(BaseAnswer, kw_only=True):
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):
"""Answer type with a list of translations.
+31
View File
@@ -256,6 +256,9 @@ plugins:
searx.plugins.tracker_url_remover.SXNGPlugin:
active: true
searx.plugins.ai_summary.SXNGPlugin:
active: false
# Configuration of the "Hostnames plugin":
#
@@ -283,6 +286,34 @@ plugins:
# '(.*\.)?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:
general:
+2
View File
@@ -13,6 +13,7 @@ from os.path import dirname, abspath
import msgspec
from typing_extensions import override
from .ai_summary import SettingsAISummary
from .brand import SettingsBrand
from .sxng_locales import sxng_locales
from ._settings import SettingsPref
@@ -267,6 +268,7 @@ SCHEMA: dict[str, t.Any] = {
'networks': {},
},
'plugins': SettingsValue(dict, {}),
'ai_summary': SettingsAISummary,
'categories_as_tabs': SettingsValue(dict, CATEGORIES_AS_TABS),
'engines': SettingsValue(list, []),
'doi_resolvers': {},
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
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
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.`)}}};export{e as t};
//# sourceMappingURL=BuurKv-k.min.js.map
@@ -1 +0,0 @@
{"version":3,"file":"BuurKv-k.min.js","names":[],"sources":["../../../../../client/simple/src/js/Plugin.ts"],"sourcesContent":["// SPDX-License-Identifier: AGPL-3.0-or-later\n\n/**\n * Base class for client-side plugins.\n *\n * @remarks\n * Handle conditional loading of the plugin in:\n *\n * - client/simple/src/js/router.ts\n *\n * @abstract\n */\nexport abstract class Plugin {\n /**\n * Plugin name.\n */\n protected readonly id: string;\n\n /**\n * @remarks\n * Don't hold references of this instance outside the class.\n */\n protected constructor(id: string) {\n this.id = id;\n\n queueMicrotask(() => this.invoke());\n }\n\n private async invoke(): Promise<void> {\n try {\n console.debug(`[PLUGIN] ${this.id}: Running...`);\n const result = await this.run();\n if (!result) return;\n\n console.debug(`[PLUGIN] ${this.id}: Running post-exec...`);\n // @ts-expect-error\n void (await this.post(result as NonNullable<Awaited<ReturnType<this[\"run\"]>>>));\n } catch (error) {\n console.error(`[PLUGIN] ${this.id}:`, error);\n } finally {\n console.debug(`[PLUGIN] ${this.id}: Done.`);\n }\n }\n\n /**\n * Plugin goes here.\n *\n * @remarks\n * The plugin is already loaded at this point. If you wish to execute\n * conditions to exit early, consider moving the logic to:\n *\n * - client/simple/src/js/router.ts\n *\n * ...to avoid unnecessarily loading this plugin on the client.\n */\n protected abstract run(): Promise<unknown>;\n\n /**\n * Post-execution hook.\n *\n * @remarks\n * The hook is only executed if `#run()` returns a truthy value.\n */\n // @ts-expect-error\n protected abstract post(result: NonNullable<Awaited<ReturnType<this[\"run\"]>>>): Promise<void>;\n}\n"],"mappings":"AAYA,IAAsB,EAAtB,KAA6B,CAI3B,GAMA,YAAsB,EAAY,CAChC,KAAK,GAAK,EAEV,mBAAqB,KAAK,OAAO,CAAC,CACpC,CAEA,MAAc,QAAwB,CACpC,GAAI,CACF,QAAQ,MAAM,YAAY,KAAK,GAAG,aAAa,EAC/C,IAAM,EAAS,MAAM,KAAK,IAAI,EAC9B,GAAI,CAAC,EAAQ,OAEb,QAAQ,MAAM,YAAY,KAAK,GAAG,uBAAuB,EAEzD,MAAY,KAAK,KAAK,CAAuD,CAC/E,OAAS,EAAO,CACd,QAAQ,MAAM,YAAY,KAAK,GAAG,GAAI,CAAK,CAC7C,QAAU,CACR,QAAQ,MAAM,YAAY,KAAK,GAAG,QAAQ,CAC5C,CACF,CAuBF"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{t as e}from"./BuurKv-k.min.js";import{i as t,t as n}from"../sxng-core.min.js";import{t as r}from"./DK4yUVpy.min.js";import{t as i}from"./DcK-mo-Y.min.js";var a=class extends e{constructor(){super(`infiniteScroll`)}async run(){let e=i(`results`).classList.contains(`only_template_images`),a=`article.result:last-child`,o=document.createElement(`div`);o.className=`loader`;let s=async i=>{let a=document.querySelector(`#search`);r(a);let s=document.querySelector(`#pagination form.next_page`);r(s);let c=a.getAttribute(`action`);if(!c)throw Error(`Form action not defined`);let l=document.querySelector(`#pagination`);r(l),l.replaceChildren(o);try{let t=await(await n(`POST`,c,{body:new FormData(s)})).text();if(!t)return;let r=new DOMParser().parseFromString(t,`text/html`),a=r.querySelectorAll(`#urls article`),o=r.querySelector(`#pagination`);document.querySelector(`#pagination`)?.remove();let l=document.querySelector(`#urls`);if(!l)throw Error(`URLs element not found`);a.length>0&&!e&&l.appendChild(document.createElement(`hr`)),l.append(...a),o&&(document.querySelector(`#results`)?.appendChild(o),i())}catch(e){console.error(`Error loading next page:`,e);let n=Object.assign(document.createElement(`div`),{textContent:t.translations?.error_loading_next_page??`Error loading next page`,className:`dialog-error`});n.setAttribute(`role`,`alert`),document.querySelector(`#pagination`)?.replaceChildren(n)}},c=new IntersectionObserver(async e=>{let[t]=e;t?.isIntersecting&&(c.unobserve(t.target),await s(()=>{let e=document.querySelector(a);e&&c.observe(e)}))},{rootMargin:`320px`}),l=document.querySelector(a);l&&c.observe(l)}async post(){}};export{a as default};
//# sourceMappingURL=D3mcqWOe.min.js.map
+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
+2
View File
@@ -0,0 +1,2 @@
import{a as e,i as t,t as n}from"../sxng-core.min.js";import{t as r}from"./DK4yUVpy.min.js";import{t as i}from"./DcK-mo-Y.min.js";var a=class extends e{constructor(){super(`infiniteScroll`)}async run(){let e=i(`results`).classList.contains(`only_template_images`),a=`article.result:last-child`,o=document.createElement(`div`);o.className=`loader`;let s=async i=>{let a=document.querySelector(`#search`);r(a);let s=document.querySelector(`#pagination form.next_page`);r(s);let c=a.getAttribute(`action`);if(!c)throw Error(`Form action not defined`);let l=document.querySelector(`#pagination`);r(l),l.replaceChildren(o);try{let t=await(await n(`POST`,c,{body:new FormData(s)})).text();if(!t)return;let r=new DOMParser().parseFromString(t,`text/html`),a=r.querySelectorAll(`#urls article`),o=r.querySelector(`#pagination`);document.querySelector(`#pagination`)?.remove();let l=document.querySelector(`#urls`);if(!l)throw Error(`URLs element not found`);a.length>0&&!e&&l.appendChild(document.createElement(`hr`)),l.append(...a),o&&(document.querySelector(`#results`)?.appendChild(o),i())}catch(e){console.error(`Error loading next page:`,e);let n=Object.assign(document.createElement(`div`),{textContent:t.translations?.error_loading_next_page??`Error loading next page`,className:`dialog-error`});n.setAttribute(`role`,`alert`),document.querySelector(`#pagination`)?.replaceChildren(n)}},c=new IntersectionObserver(async e=>{let[t]=e;t?.isIntersecting&&(c.unobserve(t.target),await s(()=>{let e=document.querySelector(a);e&&c.observe(e)}))},{rootMargin:`320px`}),l=document.querySelector(a);l&&c.observe(l)}async post(){}};export{a as default};
//# sourceMappingURL=DpvWr1cn.min.js.map
File diff suppressed because one or more lines are too long
+16 -10
View File
@@ -1,8 +1,4 @@
{
"_BuurKv-k.min.js": {
"file": "chunk/BuurKv-k.min.js",
"name": "plugin"
},
"_DK4yUVpy.min.js": {
"file": "chunk/DK4yUVpy.min.js",
"name": "assertelement"
@@ -23,6 +19,7 @@
"src/js/plugin/MapView.ts",
"src/js/plugin/InfiniteScroll.ts",
"src/js/plugin/Calculator.ts",
"src/js/plugin/AiSummary.ts",
"src/js/main/keyboard.ts",
"src/js/main/search.ts",
"src/js/main/autocomplete.ts",
@@ -80,35 +77,44 @@
"_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": {
"file": "chunk/BfLIj3Of.min.js",
"file": "chunk/C8c7HJzp.min.js",
"name": "calculator",
"src": "src/js/plugin/Calculator.ts",
"isDynamicEntry": true,
"imports": [
"_BuurKv-k.min.js",
"src/js/index.ts",
"_DcK-mo-Y.min.js"
]
},
"src/js/plugin/InfiniteScroll.ts": {
"file": "chunk/D3mcqWOe.min.js",
"file": "chunk/DpvWr1cn.min.js",
"name": "infinitescroll",
"src": "src/js/plugin/InfiniteScroll.ts",
"isDynamicEntry": true,
"imports": [
"_BuurKv-k.min.js",
"src/js/index.ts",
"_DK4yUVpy.min.js",
"_DcK-mo-Y.min.js"
]
},
"src/js/plugin/MapView.ts": {
"file": "chunk/Bc8fcwWx.min.js",
"file": "chunk/BnnvKC7b.min.js",
"name": "mapview",
"src": "src/js/plugin/MapView.ts",
"isDynamicEntry": true,
"imports": [
"_BuurKv-k.min.js"
"src/js/index.ts"
],
"css": [
"sxng-mapview.min.css"
+2 -2
View File
@@ -1,3 +1,3 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./chunk/Bc8fcwWx.min.js","./chunk/BuurKv-k.min.js","./sxng-mapview.min.css","./chunk/D3mcqWOe.min.js","./chunk/DK4yUVpy.min.js","./chunk/DcK-mo-Y.min.js","./chunk/BfLIj3Of.min.js","./chunk/C93hSkpT.min.js","./chunk/5Ako-qGW.min.js","./chunk/C21EfLAC.min.js","./chunk/od7pNHfk.min.js","./chunk/e2-9fzwE.min.js"])))=>i.map(i=>d[i]);
var e={index:`index`,results:`results`,preferences:`preferences`,unknown:`unknown`},t={closeDetail:void 0,scrollPageToSelected:void 0,selectImage:void 0,selectNext:void 0,selectPrevious:void 0},n=()=>{let t=document.querySelector(`meta[name="endpoint"]`)?.getAttribute(`content`);return t&&t in e?t:e.unknown},r=()=>{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),{}}},i=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},a=(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)},o=(e,t)=>{for(let e of t?.on??[])if(!e)return;document.readyState===`loading`?a(`DOMContentLoaded`,document,e,{once:!0}):e()},s=n(),c=r(),l=(e,t)=>{u(t)&&e()},u=e=>{switch(e.on){case`global`:return!0;case`endpoint`:return!!e.where.includes(s)}},d=`modulepreload`,f=function(e,t){return new URL(e,t).href},p={},m=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=f(t,n),t=s(t),t in p)return;p[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`:d,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)})};o(()=>{document.documentElement.classList.remove(`no-js`),document.documentElement.classList.add(`js`),a(`click`,`.close`,function(){this.parentNode?.classList.add(`invisible`)}),a(`click`,`.searxng_init_map`,async function(t){t.preventDefault(),this.classList.remove(`searxng_init_map`),l(()=>m(async()=>{let{default:e}=await import(`./chunk/Bc8fcwWx.min.js`);return{default:e}},__vite__mapDeps([0,1,2]),import.meta.url).then(({default:e})=>new e(this)),{on:`endpoint`,where:[e.results]})}),c.plugins?.includes(`infiniteScroll`)&&l(()=>m(async()=>{let{default:e}=await import(`./chunk/D3mcqWOe.min.js`);return{default:e}},__vite__mapDeps([3,1,4,5]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[e.results]}),c.plugins?.includes(`calculator`)&&l(()=>m(async()=>{let{default:e}=await import(`./chunk/BfLIj3Of.min.js`);return{default:e}},__vite__mapDeps([6,1,5,4]),import.meta.url).then(({default:e})=>new e),{on:`endpoint`,where:[e.results]})}),o(()=>{m(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([7,4]),import.meta.url),m(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([8,5,4]),import.meta.url),c.autocomplete&&m(()=>import(`./chunk/C21EfLAC.min.js`),__vite__mapDeps([9,4]),import.meta.url)},{on:[s===e.index]}),o(()=>{m(()=>import(`./chunk/C93hSkpT.min.js`),__vite__mapDeps([7,4]),import.meta.url),m(()=>import(`./chunk/od7pNHfk.min.js`),__vite__mapDeps([10,4]),import.meta.url),m(()=>import(`./chunk/5Ako-qGW.min.js`),__vite__mapDeps([8,5,4]),import.meta.url),c.autocomplete&&m(()=>import(`./chunk/C21EfLAC.min.js`),__vite__mapDeps([9,4]),import.meta.url)},{on:[s===e.results]}),o(()=>{m(()=>import(`./chunk/e2-9fzwE.min.js`),__vite__mapDeps([11,4]),import.meta.url)},{on:[s===e.preferences]});export{c as i,a as n,t as r,i as t};
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/C21EfLAC.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]}),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/C21EfLAC.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/C21EfLAC.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
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 -%}
{{- 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_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,
ValidationException,
)
import searx.ai_summary
import searx.answerers
import searx.plugins
import searx.plugins.ai_summary
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'),
# infinite scroll
'error_loading_next_page': gettext('Error loading the next page'),
# AI summary
'error_loading_ai_summary': gettext('Error loading the AI summary'),
}
@@ -975,16 +979,25 @@ def preferences():
shortcuts = {y: x for x, y in engine_shortcuts.items()},
themes = themes,
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(),
allowed_plugins = allowed_plugins,
preferences_url_params = sxng_request.preferences.get_as_url_params(),
locked_preferences = get_setting("preferences").lock,
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
)
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'])