784 lines
39 KiB
Python
784 lines
39 KiB
Python
import json, http.client, ssl, os, logging, base64, time, hashlib
|
|
from urllib.parse import urlparse
|
|
from flask import Response, request, abort
|
|
from searx.plugins import Plugin, PluginInfo
|
|
from searx.result_types import EngineResults
|
|
from flask_babel import gettext
|
|
from markupsafe import Markup
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_EXPIRY_SEC = 86400
|
|
CONNECTION_TIMEOUT_SEC = 30
|
|
|
|
PROVIDER_PRESETS = {
|
|
'openai': {'url': 'https://api.openai.com/v1/chat/completions', 'model': 'gpt-4o-mini'},
|
|
'openrouter': {'url': 'https://openrouter.ai/api/v1/chat/completions', 'model': 'google/gemma-3-27b-it:free'},
|
|
'ollama': {'url': 'http://localhost:11434/v1/chat/completions', 'model': 'llama3.2'},
|
|
'localai': {'url': 'http://localhost:8080/v1/chat/completions', 'model': 'gpt-4'},
|
|
'lmstudio': {'url': 'http://localhost:1234/v1/chat/completions', 'model': 'local-model'},
|
|
'gemini': {'url': 'https://generativelanguage.googleapis.com', 'model': 'gemma-3-27b-it'},
|
|
'azure': {'url': None, 'model': 'azure-deployment'},
|
|
'huggingface': {'url': 'https://api-inference.huggingface.co/models/{model}/v1/chat/completions', 'model': 'meta-llama/Meta-Llama-3-8B-Instruct'}
|
|
}
|
|
|
|
import typing
|
|
if typing.TYPE_CHECKING:
|
|
from searx.search import SearchWithPlugins
|
|
from searx.extended_types import SXNG_Request
|
|
from . import PluginCfg
|
|
|
|
class SXNGPlugin(Plugin):
|
|
"""
|
|
AI Answers Plugin for SearXNG.
|
|
Injects a real-time streaming answer box synthesized from search results using LLM providers.
|
|
Supports OpenAI, OpenRouter, Gemini, Ollama, LocalAI, Azure, and Hugging Face.
|
|
"""
|
|
id = "ai_answers"
|
|
|
|
def __init__(self, plg_cfg: "PluginCfg"):
|
|
super().__init__(plg_cfg)
|
|
self.info = PluginInfo(
|
|
id=self.id,
|
|
name=gettext("AI Answers Plugin"),
|
|
description=gettext("Live AI search answers using LLM providers."),
|
|
preference_section="general",
|
|
)
|
|
self._load_config()
|
|
|
|
if self.api_key:
|
|
self.secret = os.getenv('SXNG_LLM_SECRET') or hashlib.sha256(self.api_key.encode()).hexdigest()
|
|
else:
|
|
self.secret = os.getenv('SXNG_LLM_SECRET', '')
|
|
logger.warning("AI Answers: No API key configured, plugin inactive")
|
|
|
|
def _load_config(self):
|
|
self.style = os.getenv('LLM_STYLE', 'interactive')
|
|
raw_provider = os.getenv('LLM_PROVIDER', '').lower().strip()
|
|
|
|
raw_url = os.getenv('LLM_URL', '').strip()
|
|
if not raw_provider and raw_url:
|
|
url_lower = raw_url.lower()
|
|
if 'openai.com' in url_lower:
|
|
raw_provider = 'openai'
|
|
elif 'openrouter.ai' in url_lower:
|
|
raw_provider = 'openrouter'
|
|
elif ':11434' in url_lower:
|
|
raw_provider = 'ollama'
|
|
elif 'generativelanguage.googleapis.com' in url_lower:
|
|
raw_provider = 'gemini'
|
|
|
|
if not raw_provider:
|
|
logger.debug("AI Answers: No provider configured, plugin inactive")
|
|
self.provider = ''
|
|
self.model = ''
|
|
self.is_gemini = False
|
|
self.api_key = ''
|
|
return
|
|
|
|
self.provider = raw_provider if raw_provider in PROVIDER_PRESETS else 'openai'
|
|
self.is_gemini = (self.provider == 'gemini')
|
|
preset = PROVIDER_PRESETS[self.provider]
|
|
|
|
self.api_key = os.getenv('LLM_KEY', '')
|
|
if not self.api_key and self.provider in ('ollama', 'localai', 'lmstudio'):
|
|
self.api_key = 'none'
|
|
self.api_key = self.api_key.strip()
|
|
|
|
self.model = os.getenv('LLM_MODEL', preset['model']).strip()
|
|
|
|
try:
|
|
self.max_tokens = int(os.getenv('LLM_MAX_TOKENS', 500))
|
|
except ValueError:
|
|
self.max_tokens = 500
|
|
try:
|
|
self.temperature = float(os.getenv('LLM_TEMPERATURE', 0.2))
|
|
except ValueError:
|
|
self.temperature = 0.2
|
|
try:
|
|
self.context_count = max(0, int(os.getenv('LLM_CONTEXT_COUNT', 5)))
|
|
except ValueError:
|
|
self.context_count = 5
|
|
|
|
self.allowed_tabs = set(t.strip() for t in os.getenv('LLM_TABS', 'general,science,it,news').split(','))
|
|
|
|
preset_url = preset['url']
|
|
if preset_url and '{model}' in preset_url:
|
|
preset_url = preset_url.format(model=self.model)
|
|
self._parse_url(preset_url)
|
|
|
|
logger.info(f"AI Answers: {self.provider} @ {self.endpoint_host}")
|
|
|
|
def _parse_url(self, default_url):
|
|
raw_url = os.getenv('LLM_URL', '').strip() or default_url
|
|
if not raw_url.startswith(('http://', 'https://')):
|
|
raw_url = f"https://{raw_url}"
|
|
|
|
parsed = urlparse(raw_url)
|
|
self.endpoint_url = raw_url
|
|
self.endpoint_host = parsed.hostname or 'localhost'
|
|
self.endpoint_port = parsed.port
|
|
self.endpoint_path = parsed.path or '/v1/chat/completions'
|
|
if parsed.query:
|
|
self.endpoint_path += f"?{parsed.query}"
|
|
self.endpoint_ssl = (parsed.scheme == 'https')
|
|
|
|
if self.is_gemini:
|
|
return
|
|
|
|
is_local = self.endpoint_host in ('localhost', '127.0.0.1') or self.endpoint_host.startswith('127.')
|
|
if not self.endpoint_ssl and not is_local:
|
|
logger.warning(f"AI Answers: HTTP on non-localhost ({self.endpoint_host}). Credentials may be exposed.")
|
|
|
|
def _get_connection(self):
|
|
proxy_url = os.getenv('HTTPS_PROXY' if self.endpoint_ssl else 'HTTP_PROXY') or os.getenv('https_proxy' if self.endpoint_ssl else 'http_proxy')
|
|
|
|
target_host = self.endpoint_host
|
|
target_port = self.endpoint_port
|
|
target_str = f"{target_host}:{target_port}" if target_port else target_host
|
|
|
|
if proxy_url:
|
|
p = urlparse(proxy_url)
|
|
p_host = p.hostname
|
|
p_port = p.port or 8080
|
|
|
|
if p.scheme == 'https':
|
|
conn = http.client.HTTPSConnection(p_host, p_port, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context())
|
|
else:
|
|
conn = http.client.HTTPConnection(p_host, p_port, timeout=CONNECTION_TIMEOUT_SEC)
|
|
|
|
conn.set_tunnel(target_host, target_port)
|
|
return conn
|
|
|
|
# Direct Connection
|
|
if self.endpoint_ssl:
|
|
return http.client.HTTPSConnection(target_str, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context())
|
|
return http.client.HTTPConnection(target_str, timeout=CONNECTION_TIMEOUT_SEC)
|
|
|
|
def init(self, app):
|
|
@app.route('/ai-stream', methods=['POST'])
|
|
def handle_ai_stream():
|
|
data = request.json or {}
|
|
token = data.get('tk', '')
|
|
q = data.get('q', '')
|
|
lang = data.get('lang', 'all')
|
|
|
|
try:
|
|
ts, sig = token.split('.', 1)
|
|
expected = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest()
|
|
if sig != expected or (time.time() - float(ts)) > TOKEN_EXPIRY_SEC:
|
|
abort(403)
|
|
except (ValueError, KeyError, AttributeError):
|
|
abort(403)
|
|
|
|
context_text = data.get('context', '')
|
|
prev_answer = (data.get('prev_answer') or '')[-4000:]
|
|
|
|
if not self.api_key:
|
|
logger.warning(f"AI Answers: request rejected. Key loaded: {bool(self.api_key)}, Query: {bool(q)}")
|
|
return Response("Missing API key or query", status=400)
|
|
|
|
today = time.strftime("%Y-%m-%d")
|
|
target_words = int(self.max_tokens * 0.2)
|
|
lang_instruction = f" Respond in {lang}." if lang not in ('all', 'auto') else ""
|
|
|
|
SYSTEM = f"You are a search synthesis engine. Direct, grounded, citation-accurate. Today is {today}.{lang_instruction}"
|
|
|
|
CORE_RULES = [
|
|
"DENSITY 4/5: Expert-briefing level. No filler, no transitions. Every sentence = new information.",
|
|
f"BREVITY: {target_words} words max. Complete, not verbose.",
|
|
"CITATIONS: Cite [n] only for specific facts from sources. Max 3 total. Sentence-end only. Never cite common knowledge.",
|
|
"NO HEDGE: State answers confidently. Note uncertainty only if critical.",
|
|
]
|
|
|
|
if q == "Continue":
|
|
task = "CONTINUE: Pick up exactly where previous answer stopped. No repetition. Seamless flow."
|
|
elif prev_answer:
|
|
task = "FOLLOW-UP: Address the new question using prior context. Prioritize the new query."
|
|
else:
|
|
task = "ANSWER FIRST: Lead with the direct answer. No preamble, no context-setting."
|
|
|
|
grounding = "GROUNDING: Trust sources for current events. Use knowledge for fundamentals." if context_text else "GROUNDING: No sources available. Use knowledge and note 'based on general knowledge'."
|
|
history_rule = "HISTORY: Refer to prior exchange for context. Do not repeat." if prev_answer else None
|
|
|
|
instructions = [task] + CORE_RULES + [grounding]
|
|
if history_rule:
|
|
instructions.append(history_rule)
|
|
|
|
numbered_instructions = "\n".join(f"{i+1}. {r}" for i, r in enumerate(instructions))
|
|
prompt = f"""<system>{SYSTEM}</system>
|
|
|
|
<sources>
|
|
{context_text or 'None.'}
|
|
</sources>
|
|
|
|
<history>
|
|
{prev_answer or 'None.'}
|
|
</history>
|
|
|
|
<query>{q}</query>
|
|
|
|
<instructions>
|
|
{numbered_instructions}
|
|
</instructions>
|
|
|
|
<answer>"""
|
|
|
|
def stream_gemini():
|
|
path = f"/v1/models/{self.model}:streamGenerateContent"
|
|
conn = None
|
|
try:
|
|
conn = self._get_connection()
|
|
|
|
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": self.max_tokens, "temperature": self.temperature, "stopSequences": ["</answer>"]}}
|
|
headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key}
|
|
conn.request("POST", path, body=json.dumps(payload), headers=headers)
|
|
res = conn.getresponse()
|
|
if res.status != 200:
|
|
logger.error(f"Gemini API {res.status}: {res.read().decode('utf-8')}")
|
|
return
|
|
|
|
decoder = json.JSONDecoder()
|
|
buffer = ""
|
|
while True:
|
|
chunk = res.read(128)
|
|
if not chunk: break
|
|
buffer += chunk.decode('utf-8')
|
|
while buffer:
|
|
buffer = buffer.lstrip()
|
|
if not buffer: break
|
|
try:
|
|
obj, idx = decoder.raw_decode(buffer)
|
|
candidates = obj.get('candidates', [])
|
|
if candidates:
|
|
content = candidates[0].get('content', {})
|
|
parts = content.get('parts', [])
|
|
if parts:
|
|
text = parts[0].get('text', '')
|
|
if text: yield text
|
|
buffer = buffer[idx:]
|
|
except json.JSONDecodeError: break
|
|
except Exception as e:
|
|
logger.error(f"Gemini stream error: {e}")
|
|
finally:
|
|
if conn: conn.close()
|
|
|
|
def stream_openai_compatible():
|
|
conn = None
|
|
try:
|
|
conn = self._get_connection()
|
|
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"stream": True,
|
|
"max_tokens": self.max_tokens,
|
|
"temperature": self.temperature,
|
|
"stop": ["</answer>"]
|
|
}
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://github.com/searxng/searxng",
|
|
"X-Title": "SearXNG"
|
|
}
|
|
if self.provider == 'azure':
|
|
headers['api-key'] = self.api_key
|
|
else:
|
|
headers['Authorization'] = f"Bearer {self.api_key}"
|
|
|
|
conn.request("POST", self.endpoint_path, body=json.dumps(payload), headers=headers)
|
|
res = conn.getresponse()
|
|
if res.status != 200:
|
|
logger.error(f"{self.provider} API {res.status}: {res.read().decode('utf-8')}")
|
|
return
|
|
|
|
decoder = json.JSONDecoder()
|
|
buffer = b""
|
|
while True:
|
|
chunk = res.read(128)
|
|
if not chunk: break
|
|
buffer += chunk
|
|
while b"\n" in buffer:
|
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
|
line = line_bytes.decode('utf-8', errors='replace')
|
|
if line.startswith("data: "):
|
|
data_str = line[6:].strip()
|
|
if data_str == "[DONE]": return
|
|
try:
|
|
obj, _ = decoder.raw_decode(data_str)
|
|
content = obj.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
|
if content: yield content
|
|
except json.JSONDecodeError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"{self.provider} stream error: {e}")
|
|
finally:
|
|
if conn: conn.close()
|
|
|
|
generator = stream_gemini if self.is_gemini else stream_openai_compatible
|
|
return Response(generator(), mimetype='text/event-stream', headers={
|
|
'X-Accel-Buffering': 'no',
|
|
'Cache-Control': 'no-cache, no-store',
|
|
'Connection': 'keep-alive',
|
|
'Content-Encoding': 'identity'
|
|
})
|
|
return True
|
|
|
|
def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
|
|
results = EngineResults()
|
|
try:
|
|
current_tabs = set(search.search_query.categories)
|
|
if not current_tabs: current_tabs = {'general'}
|
|
|
|
if not self.active or not self.api_key or search.search_query.pageno > 1 or not self.allowed_tabs.intersection(current_tabs):
|
|
return results
|
|
|
|
raw_results = search.result_container.get_ordered_results()
|
|
context_list = []
|
|
for i, r in enumerate(raw_results[:self.context_count]):
|
|
domain = urlparse(r.get('url', '')).netloc
|
|
date = r.get('publishedDate')
|
|
date_str = f" ({date})" if date else ""
|
|
title = r.get('title') or ""
|
|
context_list.append(f"[{i+1}] {domain}{date_str}: {title}: {str(r.get('content', ''))[:500]}")
|
|
|
|
context_str = "\n".join(context_list)
|
|
|
|
|
|
ts = str(int(time.time()))
|
|
q_clean = search.search_query.query.strip()
|
|
lang = search.search_query.lang
|
|
sig = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest()
|
|
tk = f"{ts}.{sig}"
|
|
|
|
b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8')
|
|
js_q = json.dumps(q_clean)
|
|
js_lang = json.dumps(lang)
|
|
js_urls = json.dumps([r.get('url') for r in raw_results[:self.context_count]])
|
|
|
|
is_interactive = (self.style == 'interactive')
|
|
|
|
# Conditional CSS for interactive mode
|
|
interactive_css = '''
|
|
@keyframes sxng-fade-in-up {
|
|
0% { opacity: 0; transform: translateY(10px); }
|
|
100% { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.sxng-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
opacity: 0;
|
|
animation: sxng-fade-in-up 0.5s ease-out forwards;
|
|
}
|
|
.sxng-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--color-base-font, #333);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
opacity: 0.6;
|
|
}
|
|
.sxng-btn:hover {
|
|
background: var(--color-base-background-hover, rgba(0,0,0,0.05));
|
|
color: var(--color-result-link, #5e81ac);
|
|
opacity: 1;
|
|
transform: translateY(-1px);
|
|
}
|
|
.sxng-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
|
.sxng-input-wrapper {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 0.5rem;
|
|
position: relative;
|
|
}
|
|
.sxng-input {
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--color-base-font, #333);
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
font-size: 16px;
|
|
padding: 0.5rem 2.5rem 0.5rem 0;
|
|
opacity: 0.8;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.sxng-input:focus { outline: none; opacity: 1; }
|
|
.sxng-input::placeholder { color: var(--color-base-font, #333); opacity: 0.35; }
|
|
.sxng-input-line {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 0;
|
|
height: 1px;
|
|
background: var(--color-result-link, #5e81ac);
|
|
transition: width 0.3s ease;
|
|
}
|
|
.sxng-input:focus + .sxng-input-line { width: 100%; }
|
|
.sxng-user-msg {
|
|
display: block;
|
|
width: fit-content;
|
|
max-width: 80%;
|
|
margin: 1rem 0 1rem auto;
|
|
padding: 0.5rem 0.8rem;
|
|
background: var(--color-base-background-hover, rgba(0,0,0,0.05));
|
|
border-radius: 12px 12px 0 12px;
|
|
color: var(--color-base-font, #333);
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
animation: sxng-fade-in-up 0.3s ease-out forwards;
|
|
border: 1px solid var(--color-base-border, rgba(0,0,0,0.1));
|
|
}
|
|
.sxng-input-submit {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
padding: 8px;
|
|
color: var(--color-base-font, #333);
|
|
cursor: pointer;
|
|
opacity: 0.3;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.sxng-input-wrapper:focus-within .sxng-input-submit,
|
|
.sxng-input-submit:hover { opacity: 1; color: var(--color-result-link, #5e81ac); }
|
|
.sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; }
|
|
''' if is_interactive else ''
|
|
|
|
# Conditional HTML for interactive footer
|
|
interactive_html = '''
|
|
<div id="sxng-footer" class="sxng-footer" style="display:none;">
|
|
<button class="sxng-btn" id="btn-copy" title="Copy to clipboard">
|
|
<svg viewBox="0 0 24 24"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1M19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5M19 21H8V7H19V21Z"/></svg>
|
|
</button>
|
|
<button class="sxng-btn" id="btn-regen" title="Regenerate answer">
|
|
<svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4.01 7.58 4.01 12C4.01 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z"/></svg>
|
|
</button>
|
|
<form id="sxng-action-form" class="sxng-input-wrapper" onsubmit="event.preventDefault();">
|
|
<input type="text" id="sxng-action-input" class="sxng-input" placeholder="Ask..." aria-label="Ask follow-up" autocomplete="off">
|
|
<div class="sxng-input-line"></div>
|
|
<button type="submit" id="btn-action" class="sxng-input-submit" title="Send / Continue">
|
|
<svg viewBox="0 0 24 24"><path d="M19,7V11H5.83L9.41,7.41L8,6L2,12L8,18L9.41,16.59L5.83,13H21V7H19Z"/></svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
''' if is_interactive else ''
|
|
|
|
# Conditional JS for interactive handlers
|
|
interactive_js_init = '''
|
|
const footer = document.getElementById('sxng-footer');
|
|
const input = document.getElementById('sxng-action-input');
|
|
|
|
document.getElementById('btn-copy').onclick = async (e) => {
|
|
const btn = e.currentTarget;
|
|
const originalContent = btn.innerHTML;
|
|
const text = Array.from(data.childNodes)
|
|
.filter(n => n.nodeType === 3 || n.tagName === 'SPAN')
|
|
.map(n => n.textContent)
|
|
.join('');
|
|
await navigator.clipboard.writeText(text);
|
|
btn.innerHTML = '<svg viewBox="0 0 24 24" style="color:#a3be8c;"><path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z"/></svg>';
|
|
setTimeout(() => btn.innerHTML = originalContent, 2000);
|
|
};
|
|
|
|
document.getElementById('btn-regen').onclick = () => {
|
|
data.innerHTML = '<span class="sxng-cursor"></span>';
|
|
footer.style.display = 'none';
|
|
startStream();
|
|
};
|
|
|
|
const handleAction = (e) => {
|
|
if (e) e.preventDefault();
|
|
const val = input.value.trim();
|
|
const currentText = Array.from(data.childNodes)
|
|
.filter(n => n.nodeType === 3 || n.tagName === 'SPAN')
|
|
.map(n => {
|
|
if (n.classList && n.classList.contains('sxng-user-msg')) {
|
|
return '\\n\\nQ: ' + n.textContent + '\\nA: ';
|
|
}
|
|
return n.textContent;
|
|
})
|
|
.join('');
|
|
input.value = '';
|
|
input.blur();
|
|
footer.style.display = 'none';
|
|
|
|
if (val) {
|
|
const cursor = data.querySelector('.sxng-cursor');
|
|
if (cursor) cursor.remove();
|
|
const userMsg = document.createElement('span');
|
|
userMsg.className = 'sxng-user-msg';
|
|
userMsg.textContent = val;
|
|
data.appendChild(userMsg);
|
|
const newCursor = document.createElement('span');
|
|
newCursor.className = 'sxng-cursor';
|
|
data.appendChild(newCursor);
|
|
startStream(val, currentText);
|
|
} else {
|
|
const cursor = data.querySelector('.sxng-cursor');
|
|
if (cursor) cursor.remove();
|
|
data.appendChild(document.createElement('br'));
|
|
data.appendChild(document.createElement('br'));
|
|
const newCursor = document.createElement('span');
|
|
newCursor.className = 'sxng-cursor';
|
|
data.appendChild(newCursor);
|
|
startStream("Continue", currentText);
|
|
}
|
|
};
|
|
|
|
document.getElementById('sxng-action-form').onsubmit = handleAction;
|
|
input.onfocus = () => {
|
|
setTimeout(() => {
|
|
input.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
}, 300);
|
|
};
|
|
''' if is_interactive else ''
|
|
|
|
interactive_js_complete = "footer.style.display = 'flex';" if is_interactive else ''
|
|
|
|
# Streaming function signature differs between modes
|
|
stream_fn_sig = 'async function startStream(overrideQ = null, prevAnswer = null)' if is_interactive else 'async function startStream()'
|
|
stream_q = 'overrideQ || q_init' if is_interactive else 'q_init'
|
|
stream_body = f'''prev_answer: prevAnswer''' if is_interactive else ''
|
|
|
|
html_payload = f'''
|
|
<article id="sxng-stream-box" class="answer" style="display:none; margin: 1rem 0;">
|
|
<style>
|
|
@keyframes sxng-fade-pulse {{
|
|
0%, 100% {{ opacity: 0.3; }}
|
|
50% {{ opacity: 1; }}
|
|
}}
|
|
@keyframes sxng-fade-in {{
|
|
0% {{ opacity: 0; filter: blur(3px); transform: translateY(2px); }}
|
|
100% {{ opacity: 1; filter: blur(0); transform: translateY(0); }}
|
|
}}
|
|
#sxng-stream-data {{
|
|
position: relative;
|
|
margin: 0;
|
|
min-height: 1.5em;
|
|
}}
|
|
.sxng-cursor {{
|
|
display: inline-block;
|
|
width: 0.6em;
|
|
height: 1.2em;
|
|
background: var(--color-result-link, #5e81ac);
|
|
vertical-align: text-bottom;
|
|
animation: sxng-fade-pulse 1s ease-in-out infinite;
|
|
margin-right: 0.2rem;
|
|
border-radius: 2px;
|
|
}}
|
|
.sxng-chunk {{
|
|
opacity: 0;
|
|
animation: sxng-fade-in 0.4s cubic-bezier(0.2, 0.9, 0.1, 1.0) forwards;
|
|
will-change: opacity, filter, transform;
|
|
}}
|
|
{interactive_css}
|
|
</style>
|
|
<p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-result-description); font-size: 0.95rem; margin:0;"><span class="sxng-cursor"></span></p>
|
|
{interactive_html}
|
|
<script>
|
|
(async () => {{
|
|
const q_init = {js_q};
|
|
const lang_init = {js_lang};
|
|
const urls = {js_urls};
|
|
const b64_init = "{b64_context}";
|
|
const tk_init = "{tk}";
|
|
const box = document.getElementById('sxng-stream-box');
|
|
const data = document.getElementById('sxng-stream-data');
|
|
const wrapper = box.closest('.answer');
|
|
if (wrapper) wrapper.style.display = 'none';
|
|
|
|
{interactive_js_init}
|
|
|
|
{stream_fn_sig} {{
|
|
try {{
|
|
const ctx = new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0)));
|
|
if (wrapper) wrapper.style.display = '';
|
|
box.style.display = 'block';
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 60000);
|
|
const finalQ = {stream_q};
|
|
|
|
const bodyObj = {{ q: finalQ, lang: lang_init, context: ctx, tk: tk_init{', ' + stream_body if stream_body else ''} }};
|
|
const res = await fetch('/ai-stream', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify(bodyObj),
|
|
signal: controller.signal
|
|
}});
|
|
|
|
clearTimeout(timeoutId);
|
|
if (!res.ok) {{
|
|
const errSpan = document.createElement('span');
|
|
errSpan.style.color = '#bf616a';
|
|
errSpan.textContent = "Error: " + res.statusText;
|
|
data.appendChild(errSpan);
|
|
return;
|
|
}}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let cursor = data.querySelector('.sxng-cursor');
|
|
if (!cursor) {{
|
|
cursor = document.createElement('span');
|
|
cursor.className = 'sxng-cursor';
|
|
data.appendChild(cursor);
|
|
}}
|
|
|
|
let started = false;
|
|
let pendingSpace = '';
|
|
|
|
while (true) {{
|
|
const {{done, value}} = await reader.read();
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value, {{stream: true}});
|
|
if (chunk) {{
|
|
let text = chunk;
|
|
if (!started) {{
|
|
text = text.replace(/^[\\s.,;:!?]+/, '');
|
|
if (!text) continue;
|
|
if (cursor && !cursor.isConnected) data.appendChild(cursor);
|
|
started = true;
|
|
}}
|
|
|
|
if (text.trim().length === 0) {{
|
|
pendingSpace += text;
|
|
continue;
|
|
}}
|
|
|
|
if (pendingSpace) {{
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = pendingSpace;
|
|
cursor.before(s);
|
|
pendingSpace = '';
|
|
}}
|
|
|
|
const span = document.createElement('span');
|
|
span.className = 'sxng-chunk';
|
|
span.textContent = text;
|
|
cursor.before(span);
|
|
|
|
if (text.includes(']')) {{
|
|
processLastCitation();
|
|
}}
|
|
}}
|
|
}}
|
|
if (cursor) cursor.remove();
|
|
|
|
let last = data.lastChild;
|
|
while (last) {{
|
|
if (last.textContent && last.textContent.trim().length === 0) {{
|
|
const prev = last.previousSibling;
|
|
last.remove();
|
|
last = prev;
|
|
}} else {{
|
|
if (last.textContent) last.textContent = last.textContent.trimEnd();
|
|
break;
|
|
}}
|
|
}}
|
|
|
|
if (!started) {{
|
|
if (box.parentElement) box.parentElement.remove();
|
|
else box.remove();
|
|
return;
|
|
}}
|
|
|
|
{interactive_js_complete}
|
|
|
|
function processLastCitation() {{
|
|
let node = cursor ? cursor.previousSibling : data.lastChild;
|
|
let nodesRaw = [];
|
|
let buffer = '';
|
|
|
|
while (node && nodesRaw.length < 20) {{
|
|
if (node.tagName === 'SPAN' && node.className === 'sxng-chunk') {{
|
|
const content = node.textContent;
|
|
buffer = content + buffer;
|
|
nodesRaw.unshift(node);
|
|
if (content.includes('[')) break;
|
|
}} else {{
|
|
break;
|
|
}}
|
|
node = node.previousSibling;
|
|
}}
|
|
|
|
const re = /(?:\\\\)?\\[\\s*(\\d{{1,2}}(?:\\s*,\\s*\\d{{1,2}})*)\\s*(?:\\\\)?\\]/g;
|
|
let match, lastMatch;
|
|
while ((match = re.exec(buffer)) !== null) {{
|
|
lastMatch = match;
|
|
}}
|
|
|
|
if (lastMatch) {{
|
|
const before = buffer.substring(0, lastMatch.index);
|
|
const citationBody = lastMatch[1];
|
|
const after = buffer.substring(lastMatch.index + lastMatch[0].length);
|
|
nodesRaw.forEach(n => n.remove());
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
if (before) {{
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = before;
|
|
fragment.appendChild(s);
|
|
}}
|
|
|
|
citationBody.split(/\\s*,\\s*/).forEach(n => {{
|
|
const url = urls[parseInt(n)-1];
|
|
if (url) {{
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.target = '_blank';
|
|
a.style.cssText = 'text-decoration:none;color:var(--color-result-link);font-weight:bold;';
|
|
a.textContent = `[${{n}}]`;
|
|
a.className = 'sxng-chunk';
|
|
fragment.appendChild(a);
|
|
}} else {{
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = `[${{n}}]`;
|
|
fragment.appendChild(s);
|
|
}}
|
|
}});
|
|
|
|
if (after) {{
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = after;
|
|
fragment.appendChild(s);
|
|
}}
|
|
|
|
if (cursor) cursor.before(fragment);
|
|
else data.appendChild(fragment);
|
|
}}
|
|
}}
|
|
}} catch (e) {{
|
|
console.error(e);
|
|
if (box.parentElement) box.parentElement.remove();
|
|
else box.remove();
|
|
}}
|
|
}}
|
|
|
|
startStream();
|
|
}})();
|
|
</script>
|
|
</article>
|
|
'''
|
|
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload)))
|
|
except Exception as e:
|
|
logger.error(f"AI Answers: {e}")
|
|
return results
|