Replace lunr search with local search and exact-phrase ranking

Swap docusaurus-lunr-search for @easyops-cn/docusaurus-search-local. All
query words are now required to match instead of being OR-ed, so a page
spamming one common word no longer outranks the page containing the whole
query. Underscore-joined names like CONTEXT_COMPACTION_TOKEN_CAP are
indexed both whole and as sub-words, matched terms are highlighted on the
target page, and a full results page is available at /search.

Add an exact-match layer on top (src/client/exactSearch.js, wired via
webpack module replacement). Multi-word or quoted queries that occur
verbatim on a page always rank first, rendered as a single marked phrase
with a section-anchor deep link, with token-based results following. A
SearchBar wrapper (src/theme/SearchBar) highlights exactly the searched
sentence on the target page and scrolls it into view, replacing the
per-word highlight parameters for these results.

Also mention the Builtin Tools capability name on the tools page so the
in-product term is findable by search.
This commit is contained in:
Classic298
2026-07-12 13:57:34 +02:00
parent 19adde7ef7
commit 50f231d400
6 changed files with 764 additions and 747 deletions
@@ -251,7 +251,7 @@ Legacy Mode is **not** a supported workaround even for DeepSeek; it is unsupport
### Built-in System Tools (Native/Agentic Mode)
🛠️ When **Native Mode (Agentic Mode)** is enabled, Open WebUI automatically injects powerful system tools. This unlocks truly agentic behaviors where capable models (like GPT-5, Claude 4.5 Sonnet, Gemini 3 Flash, or MiniMax M2.5) can perform multi-step research, explore knowledge bases, or manage user memory autonomously.
🛠️ These are the **Builtin Tools** (the capability name shown in the Model Editor). When **Native Mode (Agentic Mode)** is enabled, Open WebUI automatically injects powerful system tools. This unlocks truly agentic behaviors where capable models (like GPT-5, Claude 4.5 Sonnet, Gemini 3 Flash, or MiniMax M2.5) can perform multi-step research, explore knowledge bases, or manage user memory autonomously.
| Tool | Purpose |
|------|---------|
+35 -1
View File
@@ -1,3 +1,5 @@
import path from "path";
import webpack from "webpack";
import { Config } from "@docusaurus/types";
import type * as Preset from "@docusaurus/preset-classic";
@@ -50,8 +52,24 @@ const config: Config = {
markdown: {
mermaid: true,
},
<<<<<<< Updated upstream
clientModules: ["./src/clientModules/ensure-gtag.js"],
themes: ["@docusaurus/theme-mermaid"],
=======
themes: [
"@docusaurus/theme-mermaid",
[
require.resolve("@easyops-cn/docusaurus-search-local"),
{
hashed: true,
indexBlog: false,
docsRouteBasePath: "/",
highlightSearchTermsOnTargetPage: true,
explicitSearchResultPath: true,
},
],
],
>>>>>>> Stashed changes
presets: [
[
@@ -198,7 +216,23 @@ const config: Config = {
},
},
} satisfies Preset.ThemeConfig,
plugins: [require.resolve("docusaurus-lunr-search")],
plugins: [
// Rank verbatim phrase matches above token results (see src/client/exactSearch.js).
() => ({
name: "docs-exact-search",
configureWebpack() {
return {
plugins: [
new webpack.NormalModuleReplacementPlugin(
/searchByWorker$/,
path.resolve(__dirname, "src/client/exactSearch.js")
),
],
};
},
}),
],
};
export default config;
+499 -744
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,10 +30,10 @@
"@docusaurus/plugin-google-gtag": "^3.7.0",
"@docusaurus/preset-classic": "^3.5.2",
"@docusaurus/theme-mermaid": "^3.5.2",
"@easyops-cn/docusaurus-search-local": "^0.55.2",
"@mdx-js/react": "^3.0.1",
"@shikijs/rehype": "^4.0.2",
"clsx": "^2.1.1",
"docusaurus-lunr-search": "^3.6.0",
"docusaurus-plugin-sass": "^0.2.5",
"marked": "^17.0.1",
"react": "^18.3.1",
+186
View File
@@ -0,0 +1,186 @@
// Drop-in replacement for @easyops-cn/docusaurus-search-local's theme/searchByWorker
// (wired via NormalModuleReplacementPlugin in docusaurus.config.ts).
// Adds an exact-substring layer over search-corpus.json: when a multi-word or
// quoted query occurs verbatim in a page, those hits rank first; token-based
// results from the original worker follow.
import * as Comlink from "comlink";
import Slugger from "github-slugger";
let remoteWorkerPromise;
function getRemoteWorker() {
if (process.env.NODE_ENV === "production" && !remoteWorkerPromise) {
remoteWorkerPromise = (async () => {
const Remote = Comlink.wrap(
new Worker(
new URL(
"../../node_modules/@easyops-cn/docusaurus-search-local/dist/client/client/theme/worker.js",
import.meta.url
)
)
);
return await new Remote();
})();
}
return remoteWorkerPromise;
}
export async function fetchIndexesByWorker(baseUrl, searchContext) {
if (process.env.NODE_ENV === "production") {
const remoteWorker = await getRemoteWorker();
await remoteWorker.fetchIndexes(baseUrl, searchContext);
}
}
let corpusPromise;
function fetchCorpus(baseUrl) {
if (!corpusPromise) {
corpusPromise = fetch(`${baseUrl}search-corpus.json`)
.then((res) => (res.ok ? res.json() : { docs: [] }))
.then((json) => (json.docs || []).map(prepareDoc))
.catch(() => []);
}
return corpusPromise;
}
export function prepareDoc(doc) {
// Strip link targets and emphasis markers (outside code fences) so matching
// approximates rendered text; keep fence lines so the heading scan can skip blocks.
// Collapse spaces/tabs but keep newlines (text and matchable stay offset-aligned).
let inFence = false;
const lines = (doc.content || "")
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => {
if (/^ {0,3}(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) {
return line;
}
return line.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*`]/g, "");
});
const text = lines.join("\n").replace(/[^\S\n]+/g, " ");
return {
title: doc.title || "Untitled",
path: doc.path || "/",
text,
matchable: text.replace(/\n/g, " ").toLowerCase(),
};
}
export function normalizeQuery(input) {
let query = input.trim();
const quoted = query.length > 2 && query.startsWith('"') && query.endsWith('"');
if (quoted) {
query = query.slice(1, -1);
}
query = query.replace(/\s+/g, " ").trim();
return { query, eligible: query.length >= 3 && (quoted || /\s/.test(query)) };
}
function nearestHeadingAnchor(text, matchIndex) {
const slugger = new Slugger();
const lineRegex = /^ {0,3}(?:(```|~~~)[^\r\n]*|(#{1,6}) +(.+?) *)$/gm;
let inFence = false;
let anchor;
let heading;
let match;
while ((match = lineRegex.exec(text)) !== null) {
if (match[1]) {
inFence = !inFence;
continue;
}
if (inFence) {
continue;
}
if (match.index > matchIndex) {
break;
}
const explicit = match[3].match(/^(.*?) *\{#([^}]+)\} *$/);
heading = (explicit ? explicit[1] : match[3]).trim();
anchor = explicit ? explicit[2] : slugger.slug(heading);
}
return anchor ? { anchor: `#${anchor}`, heading } : {};
}
function makeSnippet(text, index, length) {
let start = Math.max(0, index - 40);
let end = Math.min(text.length, index + length + 90);
if (start > 0) {
const ws = text.indexOf(" ", start);
if (ws !== -1 && ws < index) start = ws + 1;
}
if (end < text.length) {
const ws = text.lastIndexOf(" ", end);
if (ws > index + length) end = ws;
}
const prefix = start > 0 ? "… " : "";
const suffix = end < text.length ? " …" : "";
const body = text.slice(start, end).replace(/\n/g, " ");
return { snippet: prefix + body + suffix, matchStart: prefix.length + (index - start) };
}
export function searchExact(docs, query, limit) {
const q = query.toLowerCase();
const hits = [];
for (const doc of docs) {
const firstIndex = doc.matchable.indexOf(q);
if (firstIndex === -1) {
continue;
}
let count = 0;
for (let i = firstIndex; i !== -1 && count < 50; i = doc.matchable.indexOf(q, i + q.length)) {
count += 1;
}
const titleHit = doc.title.toLowerCase().includes(q);
hits.push({ doc, firstIndex, score: count + (titleHit ? 100 : 0) });
}
hits.sort((a, b) => b.score - a.score || a.doc.path.length - b.doc.path.length);
return hits.slice(0, limit).map(({ doc, firstIndex }) => {
const { anchor, heading } = nearestHeadingAnchor(doc.text, firstIndex);
const { snippet, matchStart } = makeSnippet(doc.text, firstIndex, q.length);
// Empty tokens: the dropdown then marks the whole matched range as one
// <mark>, and the plugin skips its per-word _highlight params. The phrase
// travels in our own param, marked on the target page by the SearchBar wrapper.
return {
document: {
i: `exact:${doc.path}`,
t: snippet,
s: heading || doc.title,
u: `${doc.path}?_highlight_phrase=${encodeURIComponent(query)}`,
h: anchor,
b: [],
},
type: 4, // SearchDocumentType.Content
page: { i: `exact-page:${doc.path}`, t: doc.title, u: doc.path, b: [] },
metadata: { [q]: { t: { position: [[matchStart, q.length]] } } },
tokens: [],
score: 0,
};
});
}
export async function searchByWorker(baseUrl, searchContext, input, limit) {
if (process.env.NODE_ENV !== "production") {
return [];
}
const { query, eligible } = normalizeQuery(input);
const tokenPromise = getRemoteWorker().then((worker) =>
worker.search(baseUrl, searchContext, input, limit)
);
if (!eligible) {
return tokenPromise;
}
const [exact, tokenResults] = await Promise.all([
fetchCorpus(baseUrl).then((docs) => searchExact(docs, query, limit)),
tokenPromise,
]);
if (!exact.length) {
return tokenResults;
}
const routeKey = (r) => r.document.u.split("?")[0] + (r.document.h || "");
const seen = new Set(exact.map(routeKey));
return exact.concat(tokenResults.filter((r) => !seen.has(routeKey(r)))).slice(0, limit);
}
+42
View File
@@ -0,0 +1,42 @@
// Wraps the search plugin's SearchBar. Exact-phrase results (src/client/exactSearch.js)
// navigate with ?_highlight_phrase=<phrase>; this marks that phrase on the target page
// as one unit and scrolls to it, instead of the plugin's per-word _highlight marking.
import React, { useEffect } from "react";
import { useLocation } from "@docusaurus/router";
import Mark from "mark.js";
import OriginalSearchBar from "@easyops-cn/docusaurus-search-local/dist/client/client/theme/SearchBar";
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export default function SearchBar(props) {
const location = useLocation();
useEffect(() => {
const raw = new URLSearchParams(location.search).get("_highlight_phrase");
// The /search page appends its own query string after ours; keep our part.
const phrase = raw ? raw.split("?")[0].trim() : "";
if (!phrase) {
return undefined;
}
// After the plugin's own mark effect (setTimeout 0) so its unmark() runs first.
const timer = setTimeout(() => {
const root = document.querySelector("article");
if (!root) {
return;
}
const pattern = new RegExp(
phrase.split(/\s+/).map(escapeRegExp).join("[\\s\\u00A0]+"),
"i"
);
new Mark(root).markRegExp(pattern, {
acrossElements: true,
done: () => {
root.querySelector("mark")?.scrollIntoView({ block: "center" });
},
});
}, 150);
return () => clearTimeout(timer);
}, [location.search, location.pathname]);
return <OriginalSearchBar {...props} />;
}