initial implementation
This commit is contained in:
parent
a1d5add718
commit
eaa96a0b64
@ -35,6 +35,8 @@
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"autocomplete-js": "^2.7.1"
|
||||
"autocomplete-js": "^2.7.1",
|
||||
"katex": "^0.16.19",
|
||||
"marked": "^15.0.6"
|
||||
}
|
||||
}
|
||||
|
280
client/simple/src/js/main/quick-answer.js
Normal file
280
client/simple/src/js/main/quick-answer.js
Normal file
@ -0,0 +1,280 @@
|
||||
import { marked } from "../../../node_modules/marked/lib/marked.esm.js";
|
||||
import renderMathInElement from "../../../node_modules/katex/dist/contrib/auto-render.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (typeof window.referenceMap === "undefined") {
|
||||
console.error("referenceMap is not defined");
|
||||
return;
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
highlight: function (code, language) {
|
||||
if (language) {
|
||||
return (
|
||||
'<pre><code class="language-' +
|
||||
language +
|
||||
'">' +
|
||||
code.replace(
|
||||
/[&<>'"]/g,
|
||||
(c) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[c],
|
||||
) +
|
||||
"</code></pre>"
|
||||
);
|
||||
}
|
||||
return code;
|
||||
},
|
||||
});
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.link = (token) => {
|
||||
const href = token.href;
|
||||
const title = token.title || "";
|
||||
const text = token.text;
|
||||
return `<a href="${href}" title="${title}" rel="noreferrer">${text}</a>`;
|
||||
};
|
||||
marked.use({ renderer });
|
||||
|
||||
// Custom math handling; we roll our own because LLMs are inconsistent(!)
|
||||
const mathExtension = {
|
||||
name: "math",
|
||||
level: "block",
|
||||
start(src) {
|
||||
return src.match(/\$\$/)?.index;
|
||||
},
|
||||
tokenizer(src) {
|
||||
const rule = /^\$\$([\s\S]+?)\$\$/;
|
||||
const match = rule.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: "math",
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return `$$${token.text}$$`;
|
||||
},
|
||||
};
|
||||
|
||||
const inlineMathExtension = {
|
||||
name: "inlineMath",
|
||||
level: "inline",
|
||||
start(src) {
|
||||
return src.match(/\$/)?.index;
|
||||
},
|
||||
tokenizer(src) {
|
||||
const rule = /^\$([^$\n]+?)\$/;
|
||||
const match = rule.exec(src);
|
||||
if (match) {
|
||||
return {
|
||||
type: "inlineMath",
|
||||
raw: match[0],
|
||||
text: match[1].trim(),
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return `$${token.text}$`;
|
||||
},
|
||||
};
|
||||
|
||||
marked.use({ extensions: [mathExtension, inlineMathExtension] });
|
||||
|
||||
// Answer
|
||||
const qa = document.querySelector(".infobox p");
|
||||
if (!qa) {
|
||||
console.error("Quick answer container not found");
|
||||
return;
|
||||
}
|
||||
qa.id = "quick-answer";
|
||||
qa.className = "markdown-content";
|
||||
|
||||
// References
|
||||
const refContainer = document.createElement("div");
|
||||
refContainer.className = "references";
|
||||
const refHeading = document.createElement("h4");
|
||||
refHeading.textContent = "References";
|
||||
refContainer.appendChild(refHeading);
|
||||
const refList = document.createElement("ol");
|
||||
refContainer.appendChild(refList);
|
||||
qa.after(refContainer);
|
||||
|
||||
let accumulatedText = "";
|
||||
let lastProcessedLength = 0;
|
||||
let references = {};
|
||||
let referenceCounter = 1;
|
||||
let referenceMap = window.referenceMap;
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe.replace(/[&<>"']/g, function (m) {
|
||||
switch (m) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
case "'":
|
||||
return "'";
|
||||
default:
|
||||
return m;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function replaceCitations(text) {
|
||||
// First pass: replace citations with temporary markers to be replaced by actual spaces in the second pass
|
||||
// LLMs do not consistently follow prompted formatting, and inline citations *need* to be space-delimited
|
||||
let processedText = text.replace(
|
||||
/【(\d+)】/g,
|
||||
(match, citationIndex, offset) => {
|
||||
const isFollowedByCitation = text
|
||||
.slice(offset + match.length)
|
||||
.match(/^【\d+】/);
|
||||
const source = referenceMap[citationIndex];
|
||||
|
||||
if (source) {
|
||||
const [url, title] = source;
|
||||
let refNumber = references[citationIndex];
|
||||
const escapedTitle = escapeHtml(title);
|
||||
|
||||
if (!refNumber) {
|
||||
const refItem = document.createElement("li");
|
||||
const refLink = document.createElement("a");
|
||||
refLink.href = url;
|
||||
refLink.textContent = title;
|
||||
refLink.rel = "noreferrer";
|
||||
refItem.appendChild(refLink);
|
||||
refList.appendChild(refItem);
|
||||
references[citationIndex] = referenceCounter;
|
||||
refNumber = referenceCounter;
|
||||
referenceCounter += 1;
|
||||
}
|
||||
|
||||
// Add look-ahead marker |||CITATION_SPACE||| if followed by another citation
|
||||
return `<a href="${url}" class="inline-reference" title="${escapedTitle}">${refNumber}</a>${
|
||||
isFollowedByCitation ? "|||CITATION_SPACE|||" : ""
|
||||
}`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
|
||||
// Second pass: replace temporary markers with spaces
|
||||
return processedText.replace(/\|\|\|CITATION_SPACE\|\|\|/g, " ");
|
||||
}
|
||||
|
||||
fetch("/quick_answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
system: window.systemPrompt,
|
||||
user: window.userPrompt,
|
||||
token: window.userToken,
|
||||
model: window.userModel,
|
||||
providers: window.userProviders,
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function processMarkdownChunk(text) {
|
||||
accumulatedText += text;
|
||||
|
||||
const markdownElements = {
|
||||
codeBlock: { start: "```", end: "```" },
|
||||
bold: { start: "**", end: "**" },
|
||||
italic: { start: "_", end: "_" },
|
||||
link: { start: "[", end: ")" },
|
||||
mathDisplay: { start: "$$", end: "$$" },
|
||||
mathInline: { start: "$", end: "$" },
|
||||
};
|
||||
|
||||
let processUpTo = accumulatedText.length;
|
||||
|
||||
// Find last complete element
|
||||
for (const element of Object.values(markdownElements)) {
|
||||
const lastStart = accumulatedText.lastIndexOf(element.start);
|
||||
if (lastStart > lastProcessedLength) {
|
||||
const nextEnd = accumulatedText.indexOf(
|
||||
element.end,
|
||||
lastStart + element.start.length,
|
||||
);
|
||||
if (nextEnd === -1) {
|
||||
processUpTo = Math.min(processUpTo, lastStart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process complete portion
|
||||
if (processUpTo > lastProcessedLength) {
|
||||
const processedText = replaceCitations(
|
||||
accumulatedText.substring(0, processUpTo),
|
||||
);
|
||||
qa.innerHTML = marked.parse(processedText);
|
||||
|
||||
renderMathInElement(qa, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
lastProcessedLength = processUpTo;
|
||||
}
|
||||
}
|
||||
|
||||
function readStream() {
|
||||
reader
|
||||
.read()
|
||||
.then(({ done, value }) => {
|
||||
if (done) {
|
||||
// Process any remaining text
|
||||
if (accumulatedText.length > lastProcessedLength) {
|
||||
const processedText = replaceCitations(accumulatedText);
|
||||
qa.innerHTML = marked.parse(processedText);
|
||||
renderMathInElement(qa, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
processMarkdownChunk(text);
|
||||
|
||||
// Scroll to bottom of the div to show new content
|
||||
qa.scrollTop = qa.scrollHeight;
|
||||
|
||||
// Continue reading
|
||||
readStream();
|
||||
})
|
||||
.catch((error) => console.error("Error:", error));
|
||||
}
|
||||
readStream();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
qa.innerHTML = marked.parse(`**Error**: ${error.message}`);
|
||||
});
|
||||
});
|
@ -5,3 +5,4 @@ import "./main/mapresult.js";
|
||||
import "./main/preferences.js";
|
||||
import "./main/results.js";
|
||||
import "./main/search.js";
|
||||
import "./main/quick-answer.js";
|
||||
|
@ -130,6 +130,14 @@
|
||||
// Favicons Colors
|
||||
--color-favicon-background-color: #ddd;
|
||||
--color-favicon-border-color: #ccc;
|
||||
|
||||
/// Quick Answer Colors
|
||||
--color-quick-answer-code-background: rgb(27 31 35 / 5%);
|
||||
--color-quick-answer-pre-background: #f6f8fa;
|
||||
--color-quick-answer-blockquote-border: #dfe2e5;
|
||||
--color-quick-answer-blockquote-font: #6a737d;
|
||||
--color-quick-answer-table-border: #dfe2e5;
|
||||
--color-quick-answer-table-tr-background: #f6f8fa;
|
||||
}
|
||||
|
||||
.dark-themes() {
|
||||
@ -249,6 +257,14 @@
|
||||
// Favicons Colors
|
||||
--color-favicon-background-color: #ddd;
|
||||
--color-favicon-border-color: #ccc;
|
||||
|
||||
/// Quick Answer Colors
|
||||
--color-quick-answer-code-background: #4d5a6f;
|
||||
--color-quick-answer-pre-background: #4d5a6f;
|
||||
--color-quick-answer-blockquote-border: #555;
|
||||
--color-quick-answer-blockquote-font: #bbb;
|
||||
--color-quick-answer-table-border: #555;
|
||||
--color-quick-answer-table-tr-background: #4d5a6f;
|
||||
}
|
||||
|
||||
.black-themes() {
|
||||
|
83
client/simple/src/less/quick-answer.less
Normal file
83
client/simple/src/less/quick-answer.less
Normal file
@ -0,0 +1,83 @@
|
||||
#sidebar .infobox .markdown-content code {
|
||||
background-color: var(--color-quick-answer-code-background);
|
||||
border-radius: 3px;
|
||||
font-family:
|
||||
SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 85%;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content pre {
|
||||
background-color: var(--color-quick-answer-pre-background);
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content pre > code {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content blockquote {
|
||||
border-left: 0.25em solid var(--color-quick-answer-blockquote-border);
|
||||
color: var(--color-quick-answer-blockquote-font);
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content a.inline-reference {
|
||||
color: var(--color-result-link-font);
|
||||
line-height: 1.4;
|
||||
position: relative;
|
||||
top: -0.2em;
|
||||
vertical-align: top;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content table th,
|
||||
#sidebar .infobox .markdown-content table td {
|
||||
border: 1px solid var(--color-quick-answer-table-border);
|
||||
padding: 6px 13px;
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content table tr:nth-child(2n) {
|
||||
background-color: var(--color-quick-answer-table-tr-background);
|
||||
}
|
||||
|
||||
#sidebar .infobox .markdown-content h1,
|
||||
#sidebar .infobox .markdown-content h2,
|
||||
#sidebar .infobox .markdown-content h3,
|
||||
#sidebar .infobox .markdown-content h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#sidebar .infobox .references a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#sidebar .infobox .references ol {
|
||||
list-style-type: decimal;
|
||||
list-style-position: inside;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#sidebar .infobox .references ol li {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
@ -32,6 +32,9 @@
|
||||
// to center the results
|
||||
@import "style-center.less";
|
||||
|
||||
// quick-answer plugin
|
||||
@import "quick-answer.less";
|
||||
|
||||
// sxng-icon-set
|
||||
.sxng-icon-set {
|
||||
display: inline-block;
|
||||
|
@ -22,6 +22,8 @@ const PATH = {
|
||||
brand: "src/brand",
|
||||
static: resolve(ROOT, "client/simple/static"),
|
||||
leaflet: resolve(ROOT, "client/simple/node_modules/leaflet/dist"),
|
||||
katex: resolve(ROOT, "client/simple/node_modules/katex"),
|
||||
marked: resolve(ROOT, "client/simple/node_modules/marked"),
|
||||
templates: resolve(ROOT, "searx/templates/simple"),
|
||||
};
|
||||
|
||||
@ -133,6 +135,18 @@ export default defineConfig({
|
||||
{ src: PATH.leaflet + "/leaflet.{js,js.map}", dest: PATH.dist + "/js" },
|
||||
{ src: PATH.leaflet + "/images/*.png", dest: PATH.dist + "/css/images/" },
|
||||
{ src: PATH.leaflet + "/*.{css,css.map}", dest: PATH.dist + "/css" },
|
||||
]
|
||||
}),
|
||||
|
||||
// Quick Answer (KaTeX + Marked)
|
||||
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: PATH.katex + "/dist/katex.js", dest: PATH.dist + "/js" },
|
||||
{ src: PATH.katex + "/contrib/auto-render/auto-render.js", dest: PATH.dist + "/js" },
|
||||
{ src: PATH.katex + "/dist/katex.css", dest: PATH.dist + "/css" },
|
||||
{ src: PATH.katex + "/dist/fonts/*.{ttf,woff,woff2}", dest: PATH.dist + "/css/fonts/" },
|
||||
{ src: PATH.marked + "/lib/marked.esm.js", dest: PATH.dist + "/js" },
|
||||
{ src: PATH.static + "/**/*", dest: PATH.dist },
|
||||
]
|
||||
}),
|
||||
|
@ -72,3 +72,6 @@
|
||||
|
||||
``url_formatting``:
|
||||
Formatting type to use for result URLs: ``pretty``, ``full`` or ``host``.
|
||||
|
||||
``quick_answer_api``:
|
||||
Quick Answer OpenAI-compatible API endpoint to query for search-supported LLM responses.
|
||||
|
113
searx/plugins/quick_answer.py
Normal file
113
searx/plugins/quick_answer.py
Normal file
@ -0,0 +1,113 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# pylint: disable=missing-module-docstring, missing-class-docstring
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from flask_babel import gettext
|
||||
|
||||
from searx.plugins import Plugin, PluginInfo
|
||||
|
||||
|
||||
class SXNGPlugin(Plugin):
|
||||
id = "quick_answer"
|
||||
default_on = False
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.info = PluginInfo(
|
||||
id=self.id,
|
||||
name=gettext("Quick Answer"),
|
||||
description=gettext("Use search results to obtain cited answers from LLMs by appending '?' to queries"),
|
||||
examples=["Linear congruential generator?"],
|
||||
preference_section="general/quick_answer",
|
||||
)
|
||||
|
||||
def get_sys_prompt(self):
|
||||
now = datetime.now()
|
||||
return f"""
|
||||
The current date is {now:%Y-%m-%d}
|
||||
|
||||
You ALWAYS follow these guidelines when writing your response:
|
||||
- Use markdown formatting to enhance clarity and readability of your response.
|
||||
- If you need to include mathematical expressions, use LaTeX to format them properly. Only use LaTeX when necessary for math.
|
||||
- Delimit inline mathematical expressions with '$', for example: $y = mx + b$.
|
||||
- Delimit block mathematical expressions with '$$', for example: $$F = ma$$.
|
||||
- If you need to write code or program commands, format them as markdown code blocks.
|
||||
- For all other output, use plain text formatting unless the user specifically requests otherwise.
|
||||
- DO NOT include headers which only describe or rephrase the query before beginning your response.
|
||||
- DO NOT include URLs or links in your response.
|
||||
- ALWAYS enclose currency and price values in '**', for example: **$5.99**, to ensure they are formatted correctly.
|
||||
|
||||
The relevant available information is contained within the <information></information> tags. When a user asks a question, perform the following tasks:
|
||||
0. Examine the available information and assess whether you can answer the question based on it, even if the answer is not explicitly stated. For example, if the question asks about a specific feature of a product and the available information discusses the product's features without mentioning the specific feature, you can infer that the product likely does not have that feature.
|
||||
1. Use the available information to inform your answer.
|
||||
2. When answering questions, provide inline citation references by putting their citation index delimited by 【 and 】 at end of sentence, example: This is a claim【1】."
|
||||
3. If you need to cite multiple pieces of information inline, use separate 【 and 】 for each citation, example: "This is a claim【1】【2】."
|
||||
4. Use citations most relevant to the query to augment your answer with informative supportive resources; do not create unhelpful, extended chains of citations.
|
||||
5. DO NOT list URLs/links of the citation source or an aggregate list of citations at the end of the response. They would be automatically added by the system based on citation indices.
|
||||
6. DO NOT provide inline citations inside or around code blocks, as they break formatting of output, only provide them to augment plaintext.
|
||||
7. DO NOT use markdown to format your citations, always provide them in plaintext.
|
||||
|
||||
A few guidelines for you when answering questions:
|
||||
- Highlight relevant entities/phrases with **, for example: "**Neil Armstrong** is known as the first person to land on the moon." (Do not apply this guideline to citations or in code blocks.)
|
||||
- DO NOT talk about how you based your answer on the information provided to you as it may confuse the user.
|
||||
- Don't copy-paste the information from the available information directly. Paraphrase the information in your own words.
|
||||
- Even if the information is in another format, your output MUST follow the guidelines. for example: output O₁ instead of O<sub>1</sub>, output R⁷ instead of R<sup>7</sup>, etc.
|
||||
- Be concise and informative in your answers.
|
||||
"""
|
||||
|
||||
def format_sources(self, sources):
|
||||
ret = "<available_information>\n"
|
||||
for pos, source in enumerate(sources):
|
||||
ret += "<datum>\n"
|
||||
ret += f'<citation index="{pos}">\n'
|
||||
ret += f"<source>\n{source.get('url', '')}\n</source>\n"
|
||||
ret += f"<title>\n{source.get('title', '')}\n</title>\n"
|
||||
ret += f"<content>\n{source.get('content', '')}\n</content>\n"
|
||||
ret += "</datum>\n"
|
||||
|
||||
return ret + "</available_information>"
|
||||
|
||||
def post_search(self, request, search):
|
||||
query = search.search_query
|
||||
if query.pageno > 1 or not query.query.endswith("?"):
|
||||
return
|
||||
|
||||
token = request.preferences.get_value("quick_answer_token")
|
||||
if not token:
|
||||
return
|
||||
|
||||
model = request.preferences.get_value("quick_answer_model")
|
||||
providers = request.preferences.get_value("quick_answer_providers")
|
||||
if providers:
|
||||
providers = [provider.strip() for provider in providers.split(",")]
|
||||
|
||||
sources = search.result_container.get_ordered_results()
|
||||
formatted_sources = self.format_sources(sources)
|
||||
user = formatted_sources + f"\n\nUser query: {query.query}"
|
||||
system = self.get_sys_prompt()
|
||||
|
||||
reference_map = {str(i): (source.get("url"), source.get("title")) for i, source in enumerate(sources)}
|
||||
|
||||
search.result_container.infoboxes.append(
|
||||
{
|
||||
"infobox": "Quick Answer",
|
||||
"id": "quick_answer",
|
||||
"content": f"""
|
||||
<script>
|
||||
window.systemPrompt = {json.dumps(system)};
|
||||
window.userPrompt = {json.dumps(user)};
|
||||
window.userToken = {json.dumps(token)};
|
||||
window.userModel = {json.dumps(model)};
|
||||
window.userProviders = {json.dumps(providers)};
|
||||
window.referenceMap = {json.dumps(reference_map)};
|
||||
</script>
|
||||
""",
|
||||
}
|
||||
)
|
||||
|
||||
name = gettext("Quick Answer")
|
||||
description = gettext("Use search results to obtain cited answers from LLMs by appending '?' to queries")
|
||||
default_on = False
|
||||
preference_section = "general"
|
@ -76,6 +76,26 @@ class Setting:
|
||||
class StringSetting(Setting):
|
||||
"""Setting of plain string values"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.value = ""
|
||||
|
||||
def get_value(self):
|
||||
return self.value
|
||||
|
||||
def parse(self, data: str):
|
||||
self.value = data
|
||||
|
||||
def parse_form(self, data: str):
|
||||
if self.locked:
|
||||
return
|
||||
|
||||
self.value = data
|
||||
|
||||
def save(self, name: str, resp: flask.Response):
|
||||
"""Save cookie ``name`` in the HTTP response object"""
|
||||
resp.set_cookie(name, self.value, max_age=COOKIE_MAX_AGE)
|
||||
|
||||
|
||||
class EnumStringSetting(Setting):
|
||||
"""Setting of a value which can only come from the given choices"""
|
||||
@ -132,6 +152,35 @@ class MultipleChoiceSetting(Setting):
|
||||
resp.set_cookie(name, ','.join(self.value), max_age=COOKIE_MAX_AGE)
|
||||
|
||||
|
||||
class ListSetting(Setting):
|
||||
"""Setting of values of type ``list`` (ordered comma separated string)"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.values = []
|
||||
|
||||
def get_value(self):
|
||||
return ",".join(self.values)
|
||||
|
||||
def parse(self, data: str):
|
||||
"""Parse and validate ``data`` and store the result at ``self.value``"""
|
||||
if data == "":
|
||||
self.values = []
|
||||
return
|
||||
|
||||
self.values = data.split(",")
|
||||
|
||||
def parse_form(self, data: str):
|
||||
if self.locked:
|
||||
return
|
||||
|
||||
self.values = data.split(",")
|
||||
|
||||
def save(self, name: str, resp: flask.Response):
|
||||
"""Save cookie ``name`` in the HTTP response object"""
|
||||
resp.set_cookie(name, ",".join(self.values), max_age=COOKIE_MAX_AGE)
|
||||
|
||||
|
||||
class SetSetting(Setting):
|
||||
"""Setting of values of type ``set`` (comma separated string)"""
|
||||
|
||||
@ -479,6 +528,9 @@ class Preferences:
|
||||
settings['ui']['url_formatting'],
|
||||
choices=['pretty', 'full', 'host']
|
||||
),
|
||||
"quick_answer_token": StringSetting("quick_answer_token"),
|
||||
"quick_answer_model": StringSetting("quick_answer_model"),
|
||||
"quick_answer_providers": ListSetting("quick_answer_providers"),
|
||||
# fmt: on
|
||||
}
|
||||
|
||||
|
@ -2666,3 +2666,5 @@ doi_resolvers:
|
||||
sci-hub.ru: 'https://sci-hub.ru/'
|
||||
|
||||
default_doi_resolver: 'oadoi.org'
|
||||
|
||||
quick_answer_api: "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
@ -20,6 +20,9 @@
|
||||
{% if get_setting('server.limiter') or get_setting('server.public_instance') %}
|
||||
<link rel="stylesheet" href="{{ url_for('client_token', token=link_token) }}" type="text/css">
|
||||
{% endif %}
|
||||
{% if 'Quick Answer' in get_setting('enabled_plugins') %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/katex.css') }}" type="text/css">
|
||||
{% endif %}
|
||||
<!--[if gte IE 9]>-->
|
||||
<script src="{{ url_for('static', filename='js/searxng.head.min.js') }}" client_settings="{{ client_settings }}"></script>
|
||||
<!--<![endif]-->
|
||||
|
@ -180,8 +180,9 @@
|
||||
{% if 'safesearch' not in locked_preferences %}
|
||||
{%- include 'simple/preferences/safesearch.html' -%}
|
||||
{%- endif -%}
|
||||
{%- include 'simple/preferences/tokens.html' -%}
|
||||
{{- plugin_preferences('general') -}}
|
||||
{%- include 'simple/preferences/tokens.html' -%}
|
||||
{%- include 'simple/preferences/quick_answer.html' -%}
|
||||
|
||||
|
||||
{%- if 'doi_resolver' not in locked_preferences %}
|
||||
|
39
searx/templates/simple/preferences/quick_answer.html
Normal file
39
searx/templates/simple/preferences/quick_answer.html
Normal file
@ -0,0 +1,39 @@
|
||||
<div class="pref-group">{{- _('Quick Answer') -}}</div>
|
||||
|
||||
{{- plugin_preferences('general/quick_answer') -}}
|
||||
|
||||
<fieldset>{{- '' -}}
|
||||
<legend id="quick_answer_token">{{- _('Quick Answer token') -}}</legend>{{- '' -}}
|
||||
<div class="value">{{- '' -}}
|
||||
<input name="quick_answer_token" aria-labelledby="quick_answer_token" type="text"
|
||||
autocomplete="off" spellcheck="false" autocorrect="off"
|
||||
value='{{ preferences.get_value("quick_answer_token") }}'>{{- '' -}}
|
||||
</div>{{- '' -}}
|
||||
<div class="description">
|
||||
{{- _('OpenRouter access token used to authenticate Quick Answer API requests') -}}
|
||||
</div>{{- '' -}}
|
||||
</fieldset>{{- '' -}}
|
||||
|
||||
<fieldset>{{- '' -}}
|
||||
<legend id="quick_answer_model">{{- _('Quick Answer model') -}}</legend>{{- '' -}}
|
||||
<div class="value">{{- '' -}}
|
||||
<input name="quick_answer_model" aria-labelledby="quick_answer_model" type="text"
|
||||
autocomplete="off" spellcheck="false" autocorrect="off"
|
||||
value='{{ preferences.get_value("quick_answer_model") }}'>{{- '' -}}
|
||||
</div>{{- '' -}}
|
||||
<div class="description">
|
||||
{{- _('OpenRouter LLM used to provide Quick Answers (e.g. meta-llama/llama-3.3-70b-instruct)') -}}
|
||||
</div>{{- '' -}}
|
||||
</fieldset>{{- '' -}}
|
||||
|
||||
<fieldset>{{- '' -}}
|
||||
<legend id="quick_answer_providers">{{- _('Quick Answer providers') -}}</legend>{{- '' -}}
|
||||
<div class="value">{{- '' -}}
|
||||
<input name="quick_answer_providers" aria-labelledby="quick_answer_providers" type="text"
|
||||
autocomplete="off" spellcheck="false" autocorrect="off"
|
||||
value='{{ preferences.get_value("quick_answer_providers") }}'>{{- '' -}}
|
||||
</div>{{- '' -}}
|
||||
<div class="description">
|
||||
{{- _('List of OpenRouter providers used to supply Quick Answers (e.g. Fireworks,DeepInfra)') -}}
|
||||
</div>{{- '' -}}
|
||||
</fieldset>{{- '' -}}
|
102
searx/webapp.py
102
searx/webapp.py
@ -13,6 +13,7 @@ import os
|
||||
import sys
|
||||
import base64
|
||||
|
||||
from datetime import timedelta, datetime
|
||||
from timeit import default_timer
|
||||
from html import escape
|
||||
from io import StringIO
|
||||
@ -1327,6 +1328,107 @@ def config():
|
||||
)
|
||||
|
||||
|
||||
# User-scoped cache for quick answer responses
|
||||
quick_answer_cache = {}
|
||||
quick_answer_cache_max_keys = 1000
|
||||
quick_answer_cache_expiry = timedelta(minutes=60)
|
||||
|
||||
|
||||
@app.route("/quick_answer", methods=["POST"])
|
||||
def quick_answer():
|
||||
"""Endpoint to handle LLM requests."""
|
||||
data = sxng_request.get_json()
|
||||
if not data:
|
||||
return "Invalid JSON data", 400
|
||||
|
||||
user = data.get("user")
|
||||
system = data.get("system")
|
||||
token = data.get("token")
|
||||
if not all([user, system, token]):
|
||||
return "Missing required fields", 400
|
||||
|
||||
# These can be unproblematically empty; account defaults are OK
|
||||
model = data.get("model")
|
||||
providers = data.get("providers")
|
||||
|
||||
now = datetime.now()
|
||||
expired_keys = [
|
||||
key for key, value in quick_answer_cache.items() if now - value["timestamp"] >= quick_answer_cache_expiry
|
||||
]
|
||||
for key in expired_keys:
|
||||
del quick_answer_cache[key]
|
||||
|
||||
if len(quick_answer_cache) >= quick_answer_cache_max_keys:
|
||||
sorted_keys = sorted(quick_answer_cache.keys(), key=lambda k: quick_answer_cache[k]["timestamp"])
|
||||
for key in sorted_keys[: len(quick_answer_cache) - quick_answer_cache_max_keys + 1]:
|
||||
del quick_answer_cache[key]
|
||||
|
||||
# Prevent re-generation of LLM responses when navigating to/from results pages
|
||||
query_hash = hashlib.sha256((token + model + user + system).encode("utf-8")).hexdigest()
|
||||
cached_response = quick_answer_cache.get(query_hash)
|
||||
if cached_response and datetime.now() - cached_response["timestamp"] < quick_answer_cache_expiry:
|
||||
return Response(cached_response["content"], mimetype="text/html")
|
||||
|
||||
def stream_response():
|
||||
try:
|
||||
with httpx.stream(
|
||||
method="POST",
|
||||
url=settings["quick_answer_api"],
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"provider": {"order": providers},
|
||||
"stream": True,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user,
|
||||
},
|
||||
],
|
||||
},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_buffer = []
|
||||
|
||||
for line in resp.iter_lines():
|
||||
try:
|
||||
if line.startswith("data: "):
|
||||
json_str = line[6:] # Remove 'data: ' prefix
|
||||
if json_str.strip() == "[DONE]":
|
||||
break
|
||||
|
||||
json_data = json.loads(json_str)
|
||||
if "choices" in json_data:
|
||||
content = json_data["choices"][0].get("delta", {}).get("content", "")
|
||||
if content:
|
||||
content_buffer.append(content)
|
||||
yield content
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
yield f"Error processing chunk: {str(e)}"
|
||||
break
|
||||
|
||||
if not any(
|
||||
error in "".join(content_buffer) for error in ["API request failed", "Error processing chunk"]
|
||||
):
|
||||
quick_answer_cache[query_hash] = {
|
||||
"content": "".join(content_buffer),
|
||||
"timestamp": datetime.now(),
|
||||
}
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
yield f"API request failed: {str(e)}"
|
||||
|
||||
return Response(stream_response(), mimetype="text/html")
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(_e):
|
||||
return render('404.html'), 404
|
||||
|
Loading…
x
Reference in New Issue
Block a user