[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>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// 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) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
|
||||
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 errorElement = Object.assign(document.createElement("div"), {
|
||||
textContent: settings.translations?.error_loading_ai_summary ?? "Error loading the AI summary",
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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