[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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ ready(() => {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ table {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 13.25rem;
|
||||
color: var(--color-toolkit-input-text-font);
|
||||
border: none;
|
||||
@@ -65,7 +66,8 @@ table {
|
||||
width: 15em;
|
||||
|
||||
select,
|
||||
input[type="text"] {
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
font-size: inherit !important;
|
||||
margin-top: 0;
|
||||
.ltr-margin-right(1rem);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
@import "stats.less";
|
||||
@import "result_templates.less";
|
||||
@import "weather.less";
|
||||
@import "ai_summary.less";
|
||||
|
||||
// for index.html template
|
||||
@import "index.less";
|
||||
|
||||
Reference in New Issue
Block a user