{interactive_html}
import json, os, logging, base64, time, hashlib, re, http.client, ssl, concurrent.futures, threading, math
from collections import Counter
from urllib.parse import urlparse
try:
from searx.network import get_network
except ImportError:
get_network = None
from flask import request, abort, jsonify
from searx.plugins import Plugin, PluginInfo
from searx.result_types import EngineResults
from searx import settings
from flask_babel import gettext
from markupsafe import Markup
logger = logging.getLogger(__name__)
try:
import valkey as _valkey_mod
_VALKEY_AVAILABLE = True
except ImportError:
_VALKEY_AVAILABLE = False
_valkey_mod = None
logger.warning("AI Answers: valkey package not found. Streaming via Valkey unavailable.")
TOKEN_EXPIRY_SEC = 3600
STREAM_TIMEOUT_SEC = 60
CONV_TTL = 1800
def _get_streaming_connection(url: str, verify_ssl: bool = True):
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
path = parsed.path + ('?' + parsed.query if parsed.query else '')
if verify_ssl and get_network is not None:
try:
net = get_network()
verify_ssl = getattr(net, 'verify', True)
except Exception:
pass
if parsed.scheme == 'https':
if not verify_ssl:
ctx = ssl._create_unverified_context()
else:
try:
import certifi
ctx = ssl.create_default_context(cafile=certifi.where())
except ImportError:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(host, port, timeout=STREAM_TIMEOUT_SEC, context=ctx)
else:
conn = http.client.HTTPConnection(host, port, timeout=STREAM_TIMEOUT_SEC)
return conn, path
def _tokenize(text: str) -> list:
text = text.lower()
text = re.sub(r'[^\w\s]', ' ', text)
return [t for t in text.split() if len(t) > 2]
def _tfidf_score(query_tokens: list, doc_tokens: list) -> float:
if not doc_tokens or not query_tokens:
return 0.0
doc_len = len(doc_tokens)
doc_counter = Counter(doc_tokens)
k1 = 1.5
b = 0.75
avg_len = 150
score = 0.0
for qt in query_tokens:
tf = doc_counter.get(qt, 0) / doc_len
idf = 1.0 / (1.0 + doc_counter.get(qt, 0) / max(doc_len, 1))
tf_bm25 = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_len / avg_len))
score += tf_bm25 * math.log(1 + idf)
return score
def _chunk_text(text: str, chunk_size: int = 512, overlap: int = 64) -> list:
tokens = _tokenize(text)
if len(tokens) <= chunk_size:
return [text]
chunks = []
start = 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
chunks.append(' '.join(tokens[start:end]))
if end >= len(tokens):
break
start += chunk_size - overlap
return chunks
_VALKEY_POOL = None
def _get_valkey_pool():
global _VALKEY_POOL
if _VALKEY_POOL is None:
assert _valkey_mod is not None
_VALKEY_POOL = _valkey_mod.ConnectionPool(
host=os.getenv('VALKEY_HOST', 'searxng-valkey'),
port=int(os.getenv('VALKEY_PORT', 6379)),
db=0,
decode_responses=True,
)
return _VALKEY_POOL
def _get_valkey():
if not _VALKEY_AVAILABLE or _valkey_mod is None:
raise RuntimeError("valkey package not installed")
return _valkey_mod.Valkey(connection_pool=_get_valkey_pool())
def _load_conversation(session_id: str) -> list:
try:
v = _get_valkey()
raw = v.get(f"ai:conv:{session_id}")
if raw:
return json.loads(raw)
except Exception as e:
logger.debug(f"{PLUGIN_NAME}: conv load failed: {e}")
return []
def _save_conversation(session_id: str, turns: list) -> None:
try:
v = _get_valkey()
turns = turns[-20:]
v.setex(f"ai:conv:{session_id}", CONV_TTL, json.dumps(turns))
except Exception as e:
logger.debug(f"{PLUGIN_NAME}: conv save failed: {e}")
def stream_to_valkey(job_id: str, payload: str, headers: dict, endpoint_url: str, model: str):
chunks_key = f"ai:job:{job_id}:chunks"
status_key = f"ai:job:{job_id}:status"
conn = None
try:
vk = _get_valkey()
url = endpoint_url
res = None
for _ in range(3):
conn, path = _get_streaming_connection(url)
conn.request("POST", path, body=payload.encode('utf-8'), headers=headers)
res = conn.getresponse()
if res.status in (301, 302, 307, 308):
location = res.getheader('Location', '')
res.read()
conn.close()
conn = None
if not location:
raise RuntimeError(f"Redirect {res.status} with no Location")
url = location if location.startswith('http') else \
f"{urlparse(url).scheme}://{urlparse(url).netloc}{location}"
continue
break
else:
raise RuntimeError("Too many redirects to Ollama endpoint")
if res.status != 200:
body = res.read(1024).decode('utf-8', errors='replace')
raise RuntimeError(f"Ollama error {res.status}: {body[:200]}")
think_depth = 0
pending = ''
chunk_count = 0
while True:
raw_line = res.readline()
if not raw_line:
break
line = raw_line.decode('utf-8', errors='replace').rstrip('\r\n')
if not line or not line.startswith('data: '):
continue
data_str = line[6:]
if data_str == '[DONE]':
break
try:
obj = json.loads(data_str)
except (json.JSONDecodeError, ValueError):
continue
choices = obj.get('choices', [])
if not choices:
continue
delta = choices[0].get('delta', {})
text = delta.get('content') or ''
reasoning = delta.get('reasoning') or ''
chunk = text if text else reasoning
if not chunk:
continue
pending += chunk
# Filter
$1');
text = text.replace(/((?:^|\n)[*\-+] .+)+/g, (match) => {
const items = match.trim().split('\n').map(line => {
const content = line.replace(/^[*\-+] /, '').trim();
return `');
text = text.replace(/\n(?!<)/g, '
');
return text;
}
function linkCitationsInElement(el, urls) {
const walker = document.createTreeWalker(
el, NodeFilter.SHOW_TEXT, null
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
const text = textNode.textContent;
if (!/\[\d/.test(text)) return;
const span = document.createElement('span');
span.innerHTML = text.replace(/\[(\d{1,2}(?:,\s*\d{1,2})*)\]/g, (match, nums) => {
return nums.split(/\s*,\s*/).map(n => {
const idx = parseInt(n.trim());
const url = urls[idx - 1];
if (url) {
return `[${n.trim()}]`;
}
return match;
}).join('');
});
textNode.parentNode.replaceChild(span, textNode);
});
}
function renderCitations(text, urls) {
const fragment = document.createDocumentFragment();
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
let lastIdx = 0;
const matches = [...text.matchAll(re)];
matches.forEach(match => {
if (match.index > lastIdx) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = text.substring(lastIdx, match.index);
fragment.appendChild(s);
}
match[1].split(/\s*,\s*/).forEach(n => {
const idx = parseInt(n.trim());
if (idx >= 1 && idx <= urls.length) {
const url = urls[idx-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.trim()}]`;
a.className = 'sxng-chunk';
fragment.appendChild(a);
} else {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = `[${n.trim()}]`;
fragment.appendChild(s);
}
} else {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = `[${n.trim()}]`;
fragment.appendChild(s);
}
});
lastIdx = match.index + match[0].length;
});
if (lastIdx < text.length) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
// Preserve whitespace by not trimming
s.textContent = text.substring(lastIdx);
fragment.appendChild(s);
}
return fragment;
}
function renderCitationFooter(textContent, urls, container) {
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
const usedIndices = new Set();
let m;
while ((m = re.exec(textContent)) !== null) {
m[1].split(/\s*,\s*/).forEach(n => {
const idx = parseInt(n.trim());
if (idx >= 1 && idx <= urls.length && urls[idx - 1]) {
usedIndices.add(idx);
}
});
}
if (usedIndices.size === 0) return;
const sorted = [...usedIndices].sort((a, b) => a - b);
const footer = document.createElement('div');
footer.className = 'sxng-citation-footer';
sorted.forEach(n => {
const url = urls[n - 1];
if (!url) return;
let domain;
try { domain = new URL(url).hostname.replace('www.', ''); } catch(e) { domain = url; }
const item = document.createElement('span');
item.className = 'sxng-citation-item';
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = `[${n}] ${domain}`;
item.appendChild(a);
footer.appendChild(item);
});
container.appendChild(footer);
}
'''
INTERACTIVE_JS = r'''
const footer = document.getElementById('sxng-footer');
const input = document.getElementById('sxng-action-input');
if (typeof model_init !== 'undefined' && model_init) {
const _ms = document.getElementById('sxng-model-select');
if (_ms) {
const _o = document.createElement('option');
_o.value = model_init;
_o.textContent = model_init;
_o.selected = true;
_ms.appendChild(_o);
}
}
// conversation saved as base64 URL fragment.
const updateState = () => {
try {
let state = {
t: conversation.turns.map(t => ({
r: t.role === 'user' ? 'u' : 'a',
c: t.content.replace(/\s+/g, ' ').trim()
})),
u: urls
};
const encodeB64 = (obj) => {
const u8 = new TextEncoder().encode(JSON.stringify(obj));
let bin = '';
// Use a loop to avoid RangeError: Maximum call stack size exceeded
for (let i = 0; i < u8.byteLength; i++) {
bin += String.fromCharCode(u8[i]);
}
return btoa(bin);
};
let b64 = encodeB64(state);
while (b64.length > 2000 && state.t.length > 2) {
state.t.splice(1, 2); // Delete in Q&A pairs
b64 = encodeB64(state);
}
history.replaceState(null, null, '#ai=' + b64);
} catch(e) {}
};
if (location.hash.includes('ai=')) {
try {
const b64 = location.hash.split('ai=')[1];
const uint8 = new Uint8Array(atob(b64).split('').map(c => c.charCodeAt(0)));
const json = new TextDecoder().decode(uint8);
const state = JSON.parse(json);
if (state.t && state.t.length > 0) {
// Restore URLs for citation indexing
if (state.u && Array.isArray(state.u)) {
urls = state.u;
}
conversation.turns = state.t.map(t => ({
role: t.r === 'u' ? 'user' : 'assistant',
content: t.c.trim(),
ts: 0
}));
const injectCitations = (text) => {
return renderCitations(text, urls);
};
data.innerHTML = '';
conversation.turns.forEach((turn, i) => {
if (turn.role === 'user') {
if (turn.content !== conversation.originalQuery) {
const u = document.createElement('span');
u.className = 'sxng-user-msg';
u.textContent = turn.content;
data.appendChild(u);
const clr = document.createElement('div');
clr.style.clear = 'both';
data.appendChild(clr);
}
} else {
data.appendChild(injectCitations(turn.content));
}
});
box.style.display = 'block';
if(footer && is_interactive) footer.style.display = 'flex';
restored = true;
}
} catch(e) { console.warn('Restore failed', e); }
}
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 = '';
setTimeout(() => btn.innerHTML = originalContent, 2000);
};
document.getElementById('btn-regen').onclick = async () => {
// Remove only the last assistant response and its citation footer
const lastMd = [...data.querySelectorAll('.sxng-md-content')].pop();
if (lastMd) {
const nextSib = lastMd.nextElementSibling;
if (nextSib && nextSib.classList.contains('sxng-citation-footer')) nextSib.remove();
lastMd.remove();
}
const existingCursor = data.querySelector('.sxng-cursor');
if (existingCursor) existingCursor.remove();
const regenCursor = document.createElement('span');
regenCursor.className = 'sxng-cursor';
data.appendChild(regenCursor);
footer.style.display = 'none';
if (conversation.turns.length > 0 && conversation.turns[conversation.turns.length - 1].role === 'assistant') {
conversation.turns.pop();
}
updateState();
if (conversation.turns.length <= 1) {
await startStream();
} else {
const val = conversation.turns[conversation.turns.length - 1].content;
const currentText = conversation.turns.slice(0, -1).slice(-6)
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
.join('\\n\\n');
await startStream(val, currentText);
}
updateState();
};
const btnClearHistory = document.getElementById('btn-clear-history');
if (btnClearHistory) {
btnClearHistory.onclick = async () => {
if (!session_id_init) return;
try {
await fetch(`${script_root}/ai-conversation`, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tk: tk_init, session_id: session_id_init})
});
} catch(e) {}
conversation.turns = [{role: 'user', content: q_init, ts: Date.now()}];
location.reload();
};
}
const handleAction = async (e) => {
if (e) e.preventDefault();
const val = input.value.trim();
conversation.turns.push({role: 'user', content: val, ts: Date.now()});
updateState();
const currentText = conversation.turns.slice(0, -1).slice(-6)
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
.join('\\n\\n');
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 clr = document.createElement('div');
clr.style.clear = 'both';
data.appendChild(clr);
const newCursor = document.createElement('span');
newCursor.className = 'sxng-cursor';
data.appendChild(newCursor);
const synthesized = synthesizeQuery(q_init, val);
let auxContext = null;
try {
const auxData = await fetch(script_root + '/ai-auxiliary-search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: synthesized, lang: lang_init, offset: urls.length, tk: tk_init, session_id: session_id_init})
}).then(r => r.json());
if (auxData.context) {
const originalBackground = conversation.originalContext.substring(0, 1500);
auxContext = `FRESH SOURCES (most relevant):\\n${auxData.context}\\n\\nBACKGROUND (for reference):\\n${originalBackground}`;
if (auxData.new_urls && Array.isArray(auxData.new_urls)) {
urls = urls.concat(auxData.new_urls);
}
}
} catch (err) {}
await startStream(val, currentText, auxContext);
updateState();
} 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);
await startStream("Continue", currentText);
updateState();
}
};
document.getElementById('sxng-action-form').onsubmit = handleAction;
input.onfocus = () => {
setTimeout(() => {
input.scrollIntoView({behavior: 'smooth', block: 'center'});
}, 300);
};
(function fetchModels() {
const _msel2 = document.getElementById('sxng-model-select');
if (!_msel2) return;
const _modelsUrl = script_root + '/ai-models?tk=' + encodeURIComponent(tk_init);
console.log('[AI Answers] Fetching models from', _modelsUrl);
fetch(_modelsUrl)
.then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
.then(d => {
const models = (d && d.models && d.models.length > 0) ? d.models : [model_init];
const _cur = _msel2.value || model_init;
_msel2.innerHTML = '';
models.forEach(m => {
const o = document.createElement('option');
o.value = m; o.textContent = m;
if (m === _cur) o.selected = true;
_msel2.appendChild(o);
});
_msel2.style.display = 'inline-block';
})
.catch(() => {
if (model_init) {
const o = document.createElement('option');
o.value = model_init; o.textContent = model_init;
o.selected = true;
_msel2.appendChild(o);
_msel2.style.display = 'inline-block';
}
});
})();
'''
FRONTEND_JS_TEMPLATE = r"""
(async () => {
const is_interactive = __IS_INTERACTIVE__;
const q_init = __JS_Q__;
const lang_init = __JS_LANG__;
let urls = __JS_URLS__;
const b64_init = __B64_CONTEXT__;
const tk_init = __TK__;
const script_root = __SCRIPT_ROOT__;
const model_init = __MODEL_INIT__;
const session_id_init = __SESSION_ID__;
const intent_init = __INTENT__;
if (session_id_init && !document.cookie.includes('sxng_ai_session')) {
document.cookie = `sxng_ai_session=${session_id_init}; path=/; max-age=1800; SameSite=Lax`;
}
const conversation = {
originalQuery: q_init,
originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))),
turns: [{role: 'user', content: q_init, ts: Date.now()}]
};
const box = document.getElementById('sxng-stream-box');
const data = document.getElementById('sxng-stream-data');
(function applyTheme() {
try {
const root = document.documentElement;
const s = getComputedStyle(root);
const get = (v, fallback) => s.getPropertyValue(v).trim() || fallback;
const theme = {
'--color-answer-background': get('--color-answer-background', '#313338'),
'--color-answer-font': get('--color-answer-font', '#fff'),
'--color-result-link': get('--color-result-link', '#8aacf7'),
'--color-base-font': get('--color-base-font', '#cdd6f4'),
'--color-sidebar-bg': get('--color-sidebar-bg', '#424247'),
'--color-result-hover': get('--color-result-hover', '#303033'),
'--color-base-background': get('--color-base-background', '#2a2a2e'),
'--color-search-font': get('--color-search-font', '#bbb'),
'--color-result-border': get('--color-result-border', '#4c566a'),
'--color-result-description':get('--color-result-description', '#d8dee9'),
'--color-toolkit-select-background': get('--color-toolkit-select-background', '#313338'),
};
// Apply to box and any ai-answers container
const targets = [box, document.getElementById('ai-answers')].filter(Boolean);
targets.forEach(el => {
Object.entries(theme).forEach(([k, v]) => {
if (v) el.style.setProperty(k, v);
});
});
} catch(e) {}
})();
// Move AI Overview outside #answers, place it before #results
(function relocateBox() {
const answersDiv = document.getElementById('answers');
if (!box || !answersDiv) return;
// Create our own container
const aiContainer = document.createElement('div');
aiContainer.id = 'ai-answers';
const rootStyle = getComputedStyle(document.documentElement);
const getVar = (v, fb) => rootStyle.getPropertyValue(v).trim() || fb;
const bg = getVar('--color-answer-background', '');
const answerFont = getVar('--color-answer-font', '');
// Detect light mode by checking if answer font is dark
const isLight = answerFont && (answerFont.includes('0,0,0') ||
answerFont.includes('#000') || answerFont.includes('#333') ||
answerFont.includes('#444') || answerFont.includes('rgb(0') ||
answerFont.includes('rgb(3') || answerFont.includes('rgb(4') ||
answerFont.includes('rgb(5') || answerFont.includes('rgb(6'));
const containerBg = isLight
? 'rgba(0,0,0,0.06)'
: (bg || 'var(--color-answer-background, #313338)');
aiContainer.style.cssText = [
`background: ${containerBg}`,
'padding: 1rem',
'margin: 0 0 1rem 0',
`color: ${getVar('--color-answer-font', 'var(--color-answer-font, #fff)')}`,
'border-radius: 8px',
'box-sizing: border-box',
'width: 100%'
].join('; ');
// Move our box into the new container
aiContainer.appendChild(box);
const resultsGrid = document.getElementById('results');
if (resultsGrid) {
// Insert as first child of #results grid so grid-area:answers applies
resultsGrid.insertBefore(aiContainer, resultsGrid.firstChild);
} else {
answersDiv.parentNode.insertBefore(aiContainer, answersDiv);
}
// Hide #answers entirely since our box is now elsewhere
answersDiv.style.display = 'none';
})();
let restored = false;
let isStreaming = false;
__CITATION_HELPER_JS__
(function applyIntentBadge() {
const intentEmoji = {factual:'📖',howto:'🔧',technical:'⌨️',comparison:'⚖️',opinion:'💬',current:'📰',local:'📍'}[intent_init] || '';
if (intentEmoji) {
const label = box ? box.querySelector('.sxng-ai-label') : null;
if (label) label.innerHTML += ` ${intentEmoji}`;
}
})();
__INTERACTIVE_JS_INIT__
async function loadPriorConversation() {
if (!session_id_init) return;
try {
const res = await fetch(
`${script_root}/ai-conversation?tk=${encodeURIComponent(tk_init)}&session_id=${session_id_init}`
);
if (!res.ok) return;
const d = await res.json();
const turns = d.turns || [];
if (turns.length === 0) return;
const historyDiv = document.createElement('details');
historyDiv.className = 'sxng-prior-history';
historyDiv.innerHTML = '