import json, os, logging, base64, typing, 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 ... blocks, push clean content immediately while True: if think_depth == 0: think_start = pending.find('') if think_start == -1: if pending: vk.rpush(chunks_key, pending) vk.expire(chunks_key, 120) chunk_count += 1 pending = '' break else: before = pending[:think_start] if before: vk.rpush(chunks_key, before) vk.expire(chunks_key, 120) chunk_count += 1 pending = pending[think_start + 7:] think_depth = 1 else: think_end = pending.find('') if think_end == -1: break else: pending = pending[think_end + 8:] think_depth = 0 if think_depth == 0 and pending: vk.rpush(chunks_key, pending) vk.expire(chunks_key, 120) chunk_count += 1 logger.debug(f"AI Answers: stream complete, wrote {chunk_count} chunks") vk.rpush(chunks_key, '__DONE__') vk.expire(chunks_key, 120) vk.set(status_key, 'done', ex=120) except Exception as e: logger.error(f"AI Answers: stream_to_valkey error for job {job_id}: {e}", exc_info=True) try: vk2 = _get_valkey() vk2.rpush(chunks_key, f"__ERROR__{e}") vk2.expire(chunks_key, 120) vk2.set(status_key, 'error', ex=120) except Exception: pass finally: if conn: try: conn.close() except Exception: pass PLUGIN_NAME = "AI Answers" DEFAULT_TABS = "general,science,it,news" # UI assets 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: 1rem; 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 var(--color-result-border, rgba(0,0,0,0.1)); border-radius: 4px; background: var(--color-base-background-hover, rgba(0,0,0,0.06)); color: var(--color-base-font, inherit); cursor: pointer; vertical-align: middle; line-height: 1.4; } .sxng-btn:hover { background: var(--color-result-border, rgba(0,0,0,0.15)); color: var(--color-base-font, inherit); } .sxng-btn svg { width: 18px; height: 18px; fill: currentColor; } .sxng-input-wrapper { flex-grow: 1; display: flex; height: 32px; align-items: center; margin: 0 0.5rem; position: relative; } .sxng-input { width: 100%; height: -webkit-fill-available; background: var(--color-base-background-hover, rgba(0,0,0,0.06)); border: 1px solid var(--color-result-border, rgba(0,0,0,0.15)); color: var(--color-base-font, inherit); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 0.78em; padding: 3px 8px; border-radius: 4px; line-height: 1.4; vertical-align: middle; } .sxng-input:focus { outline: none; } .sxng-input::placeholder { color: var(--color-base-font, inherit); opacity: 0.4; } .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: 0.75rem 0 0.75rem auto; padding: 0.25rem 0.6rem 0.25rem 0; border-right: 2px solid var(--color-result-link, #5e81ac); text-align: right; font-size: 0.85rem; line-height: 1.4; opacity: 0.55; animation: sxng-fade-in-up 0.3s ease-out forwards; } .sxng-input-wrapper:focus-within { opacity: 1; color: var(--color-result-link, #5e81ac); background: var(--color-base-background-hover, rgba(0,0,0,0.05)) !important; } .sxng-model-select { appearance: none; -webkit-appearance: none; background: url("data:image/svg+xml;charset=UTF-8,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%0A%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%3E%0A%3Cg%20fill%3D%22%23aaa%22%3E%0A%3Cpolygon%20points%3D%22128%2C192%20256%2C320%20384%2C192%22%2F%3E%3C%2Fg%3E%0A%3C%2Fsvg%3E") calc(100% + 2rem) / 1rem no-repeat content-box border-box; background-color: var(--color-base-background-hover, rgba(0,0,0,0.06)); text-overflow: ellipsis; border: 0px solid var(--color-result-border, rgba(0,0,0,0.1)); border-right-width: 2rem; border-right-color: transparent; border-radius: 5px; outline: none; height: 25px; color: var(--color-base-font, inherit); font-size: .9rem; padding: 1px 10px 1px 10px !important; margin: 0; cursor: pointer; display: none; max-width: 8rem; vertical-align: middle; } .sxng-model-select:hover { background-color: var(--color-result-border, rgba(0,0,0,0.15)); } .sxng-reasoning { margin: 0.5rem 0; padding: 0.5rem; border-left: 2px solid var(--color-result-link, #5e81ac); background: var(--color-base-background-hover, rgba(0,0,0,0.03)); font-size: 0.85rem; opacity: 0.7; transition: opacity 0.2s; } .sxng-reasoning:hover { opacity: 1; } .sxng-reasoning summary { cursor: pointer; font-weight: bold; color: var(--color-result-link, #5e81ac); } .sxng-thought-content { margin-top: 0.5rem; white-space: pre-wrap; font-family: monospace; } .sxng-citation-footer { margin-top: 0.75rem; padding-top: 0.5rem; border-top: 1px solid var(--color-sidebar-bg, #424247); display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; } .sxng-citation-item a { font-size: 0.75em; color: var(--color-result-link, #5e81ac); text-decoration: none; opacity: 1; font-weight: 600; } .sxng-citation-item a:hover { opacity: 1; text-decoration: underline; } .sxng-prior-history { margin-bottom: 0.75rem; padding: 0.5rem; border-left: 2px solid var(--color-result-link, #5e81ac); opacity: 0.85; font-size: 0.85em; } .sxng-prior-history summary { cursor: pointer; color: var(--color-result-link, #5e81ac); font-weight: 700; } .sxng-prior-answer { margin: 0.25rem 0; padding-left: 0.5rem; color: var(--color-base-font, inherit); } .sxng-md-content { line-height: 1.6; } .sxng-md-content ul, .sxng-md-content ol { margin: 0.5rem 0; padding-left: 1.5rem; } .sxng-md-content li { margin: 0.2rem 0; } .sxng-md-content p { margin: 0.4rem 0; } .sxng-md-content code { font-family: monospace; } ''' INTERACTIVE_HTML = ''' ''' CITATION_HELPER_JS = r''' function parseMarkdown(text) { text = text.replace(/\*\*(.*?)\*\*/g, '$1'); text = text.replace(/__(.*?)__/g, '$1'); text = text.replace(/(?$1'); text = text.replace(/(?$1'); text = text.replace(/`([^`]+)`/g, '$1'); text = text.replace(/((?:^|\n)[*\-+] .+)+/g, (match) => { const items = match.trim().split('\n').map(line => { const content = line.replace(/^[*\-+] /, '').trim(); return `
  • ${content}
  • `; }).join(''); return ``; }); text = text.replace(/((?:^|\n)\d+\. .+)+/g, (match) => { const items = match.trim().split('\n').map(line => { const content = line.replace(/^\d+\. /, '').trim(); return `
  • ${content}
  • `; }).join(''); return `
      ${items}
    `; }); text = text.replace(/^### (.+)$/gm, '

    $1

    '); text = text.replace(/^## (.+)$/gm, '

    $1

    '); text = text.replace(/^# (.+)$/gm, '

    $1

    '); text = text.replace(/^---+$/gm, '
    '); text = text.replace(/\n\n/g, '

    '); 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 = '

    Prior conversation'; turns.slice(-6).forEach(turn => { const el = document.createElement('div'); el.className = turn.role === 'user' ? 'sxng-user-msg' : 'sxng-prior-answer'; el.textContent = turn.content; historyDiv.appendChild(el); }); data.insertBefore(historyDiv, data.firstChild); } catch(e) { console.debug('[AI Answers] Could not load prior conversation:', e); } } async function saveConversationTurn() { if (!session_id_init) return; try { const turns = conversation.turns.slice(-20); await fetch(`${script_root}/ai-conversation`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({tk: tk_init, session_id: session_id_init, turns: turns}) }); } catch(e) { console.debug('[AI Answers] Could not save conversation:', e); } } function synthesizeQuery(original, followup) { const cleanOrig = original.replace(/^(what|how|why|when|where|who|which|is|are|can|does|do)(\s+(is|are|do|does|can|to|a|an|the))?\s+/i, ''); const origWords = cleanOrig.split(' ').slice(0, 12); return `${origWords.join(' ')} ${followup}`.trim(); } __STREAM_FN_SIG__ { if (isStreaming) { console.warn('[AI Answers] Stream already in progress, ignoring duplicate call'); return; } isStreaming = true; try { const ctx = auxContext || conversation.originalContext; box.style.display = 'block'; const controller = new AbortController(); let timeoutId = setTimeout(() => controller.abort(), 90000); const finalQ = __STREAM_Q__; const _selMdl = (document.getElementById('sxng-model-select') || {value: ''}).value; const bodyObj = { q: finalQ, lang: lang_init, context: ctx, tk: tk_init, model: _selMdl, session_id: session_id_init__STREAM_BODY__ }; const res = await fetch(script_root + '/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 respJson = await res.json(); if (respJson.error) { const cursorErr = data.querySelector('.sxng-cursor'); if (cursorErr) cursorErr.remove(); const errSpan = document.createElement('span'); errSpan.style.color = '#bf616a'; errSpan.textContent = "⚠️ " + respJson.error; data.appendChild(errSpan); return; } const jobId = respJson.job_id; if (!jobId) { const cursorErr = data.querySelector('.sxng-cursor'); if (cursorErr) cursorErr.remove(); const errSpan = document.createElement('span'); errSpan.style.color = '#bf616a'; errSpan.textContent = 'No job ID returned. Check server logs.'; data.appendChild(errSpan); return; } let cursor = data.querySelector('.sxng-cursor'); if (!cursor) { cursor = document.createElement('span'); cursor.className = 'sxng-cursor'; data.appendChild(cursor); } const streamContainer = document.createElement('div'); streamContainer.className = 'sxng-stream-container'; if (cursor) cursor.before(streamContainer); else data.appendChild(streamContainer); let buffer = ''; let fullText = ''; const flushBuffer = (force = false) => { if (!buffer) return; if (force) { const fragment = renderCitations(buffer, urls); streamContainer.appendChild(fragment); buffer = ''; return; } while (true) { const match = buffer.match(/(\[\d+(?:,\s*\d+)*\])/); if (!match) break; const preText = buffer.substring(0, match.index); if (preText) { const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = preText; streamContainer.appendChild(s); } const citationText = match[0]; const fragment = renderCitations(citationText, urls); streamContainer.appendChild(fragment); buffer = buffer.substring(match.index + match[0].length); } const openIdx = buffer.lastIndexOf('['); if (openIdx === -1) { if (buffer) { const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = buffer; streamContainer.appendChild(s); buffer = ''; } } else { const safeChunk = buffer.substring(0, openIdx); if (safeChunk) { const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = safeChunk; streamContainer.appendChild(s); } buffer = buffer.substring(openIdx); if (buffer.length > 50) { const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = buffer[0]; streamContainer.appendChild(s); buffer = buffer.substring(1); } } }; let offset = 0; const maxPolls = 600; let polls = 0; while (polls < maxPolls) { polls++; await new Promise(r => setTimeout(r, 150)); let statusRes; try { statusRes = await fetch( `${script_root}/ai-status/${jobId}?tk=${encodeURIComponent(tk_init)}&offset=${offset}`, { signal: controller.signal } ); } catch (fetchErr) { if (fetchErr.name === 'AbortError') throw fetchErr; continue; } if (statusRes.status === 404) { const cursorE = data.querySelector('.sxng-cursor'); if (cursorE) cursorE.remove(); const expiredSpan = document.createElement('span'); expiredSpan.style.color = '#bf616a'; expiredSpan.textContent = 'Response expired. Please search again.'; data.appendChild(expiredSpan); return; } if (!statusRes.ok) continue; const statusData = await statusRes.json(); if (statusData.error) { const cursorE = data.querySelector('.sxng-cursor'); if (cursorE) cursorE.remove(); const errSpan2 = document.createElement('span'); errSpan2.style.color = '#bf616a'; errSpan2.textContent = '⚠️ ' + statusData.error; data.appendChild(errSpan2); return; } for (const chunk of (statusData.chunks || [])) { fullText += chunk; buffer += chunk; flushBuffer(false); } offset += (statusData.chunks || []).length; if (statusData.done) { flushBuffer(true); break; } } streamContainer.remove(); if (cursor) cursor.remove(); const rendered = parseMarkdown(fullText.trim()); const mdDiv = document.createElement('div'); mdDiv.className = 'sxng-md-content'; mdDiv.innerHTML = rendered; linkCitationsInElement(mdDiv, urls); data.appendChild(mdDiv); renderCitationFooter(fullText, urls, data); const collectedResponse = fullText; __INTERACTIVE_JS_COMPLETE__ if (collectedResponse) { conversation.turns.push({role: 'assistant', content: collectedResponse.trim(), ts: Date.now()}); await saveConversationTurn(); } if (arguments.length === 0 && typeof updateState === 'function') { updateState(); } } catch (e) { console.error('[AI Answers] Fatal stream exception:', e); const errSpan = document.createElement('span'); errSpan.style.cssText = 'color: #bf616a; font-weight: bold; display: block; margin-top: 0.5rem;'; if (e.name === 'AbortError') { errSpan.textContent = "⚠️ Connection to AI provider timed out."; } else { errSpan.textContent = "⚠️ AI Widget encountered a fatal error. Check browser console."; } if (data) { const cursor = data.querySelector('.sxng-cursor'); if (cursor) cursor.remove(); data.appendChild(errSpan); } } finally { isStreaming = false; } } await loadPriorConversation(); if (!restored) startStream(); })(); """ def _detect_intent(query: str) -> str: q = query.lower().strip() if any(w in q for w in ['news', 'latest', 'recent', 'today', 'yesterday', 'this week', 'breaking', '2025', '2026', 'update']): return 'current' if any(q.startswith(p) for p in ['how to', 'how do i', 'how can i', 'how do you', 'steps to', 'guide to', 'tutorial', 'how does']): return 'howto' if any(w in q for w in ['install', 'configure', 'setup', 'set up', 'enable', 'disable', 'fix', 'repair']): return 'howto' if any(w in q for w in ['error', 'exception', 'traceback', 'debug', 'code', 'function', 'script', 'api', 'command', 'terminal', 'bash', 'python', 'javascript', 'docker', 'linux', 'git', 'sql', 'regex']): return 'technical' if ' vs ' in q or ' versus ' in q or 'difference between' in q or \ 'compare ' in q or (' or ' in q and len(q.split()) < 8): return 'comparison' if any(q.startswith(p) for p in ['best ', 'top ', 'worst ', 'should i', 'is it worth', 'recommend']): return 'opinion' if any(w in q for w in ['worth it', 'better than', 'best way', 'recommend', 'suggestion', 'advice']): return 'opinion' if any(w in q for w in ['near me', 'nearby', 'local', 'in my area', 'closest', 'directions to']): return 'local' if any(q.startswith(p) for p in ['what is', 'what are', 'who is', 'who was', 'when did', 'when was', 'where is', 'where was', 'why is', 'why does', 'define ', 'what does']): return 'factual' return 'general' INTENT_CONFIGS = { 'factual': { 'system_suffix': ( "This is a factual question. Provide a direct, accurate definition " "or explanation. Lead with the core fact. Cite your primary source. " "2-3 sentences maximum." ), 'task': "DEFINE: State the fact or definition directly. No preamble.", 'format': "Plain prose. No lists. Cite the most authoritative source first." }, 'howto': { 'system_suffix': ( "This is a how-to question. Provide clear, actionable steps. " "Be specific and practical. Number the key steps if there are more than 2." ), 'task': "INSTRUCT: Give the key steps or method directly. Be actionable.", 'format': "Numbered steps if 3+, otherwise prose. Cite sources for each step." }, 'technical': { 'system_suffix': ( "This is a technical question. Be precise and specific. " "Include exact commands, syntax, or error explanations where relevant. " "Prioritize official documentation and technical sources." ), 'task': "TECHNICAL: Provide the precise technical answer. Include specifics.", 'format': "Exact terminology. Commands in backticks if applicable. Cite docs." }, 'comparison': { 'system_suffix': ( "This is a comparison question. Objectively compare the options. " "Highlight key differences. Avoid picking a winner unless sources clearly support it." ), 'task': "COMPARE: State the key differences between the options directly.", 'format': "Brief parallel structure. Cite a source for each side if available." }, 'opinion': { 'system_suffix': ( "This is a recommendation or opinion question. Synthesize what sources say. " "Present the consensus view if one exists. Note disagreement if present. " "Do not present personal opinions as fact." ), 'task': "SYNTHESIZE: State what sources recommend or what consensus says.", 'format': "Lead with the consensus. Note any caveats. Cite sources." }, 'current': { 'system_suffix': ( "This is a current events question. Prioritize the most recent sources. " "Note the date of information if relevant. Be clear about what is known vs uncertain." ), 'task': "REPORT: State the latest known information directly. Note recency.", 'format': "Lead with most recent fact. Include dates where available. Cite news sources." }, 'local': { 'system_suffix': ( "This is a local or location-based question. " "Provide relevant location-specific information from sources. " "Note if information may vary by location." ), 'task': "LOCAL: Provide location-relevant information from sources.", 'format': "Be specific to the location context. Cite local sources." }, 'general': { 'system_suffix': ( "Provide a concise, accurate overview that directly answers the query." ), 'task': "ANSWER FIRST: Lead with the direct answer. No preamble.", 'format': "2-4 sentences. Cite most relevant sources." }, } if typing.TYPE_CHECKING: from searx.search import SearchWithPlugins from searx.extended_types import SXNG_Request from . import PluginCfg class SXNGPlugin(Plugin): id = "ai_answers" def __init__(self, plg_cfg: "PluginCfg"): super().__init__(plg_cfg) self.info = PluginInfo( id=self.id, name=gettext(f"{PLUGIN_NAME} Plugin"), description=gettext("Live AI search answers using LLM providers."), preference_section="general", ) self._load_config() def _load_config(self): self.interactive = os.getenv('LLM_INTERACTIVE', 'true').lower().strip() in ('true', '1', 'yes', 'on') self.question_mark_required = os.getenv('LLM_QUESTION_MARK_REQUIRED', 'false').lower().strip() in ('true', '1', 'yes', 'on') raw_url = os.getenv('LLM_URL', 'http://ollama:11434/v1/chat/completions').strip() if not raw_url.startswith(('http://', 'https://')): raw_url = f"http://{raw_url}" self.endpoint_url = raw_url self.api_key = 'ollama' self.model = os.getenv('LLM_MODEL', 'qwen3.5:9b').strip() try: self.max_tokens = max(1, int(os.getenv('LLM_MAX_TOKENS', 200))) except ValueError: logger.warning(f"{PLUGIN_NAME}: Invalid LLM_MAX_TOKENS value. Enforcing default (200).") self.max_tokens = 200 try: self.temperature = float(os.getenv('LLM_TEMPERATURE', 0.2)) except ValueError: logger.warning(f"{PLUGIN_NAME}: Invalid LLM_TEMPERATURE value. Enforcing default (0.2).") self.temperature = 0.2 try: self.context_deep_count = max(0, int(os.getenv('LLM_CONTEXT_DEEP_COUNT', 5))) except ValueError: logger.warning(f"{PLUGIN_NAME}: Invalid LLM_CONTEXT_DEEP_COUNT value. Enforcing default (5).") self.context_deep_count = 5 try: self.context_shallow_count = max(0, int(os.getenv('LLM_CONTEXT_SHALLOW_COUNT', 15))) except ValueError: logger.warning(f"{PLUGIN_NAME}: Invalid LLM_CONTEXT_SHALLOW_COUNT value. Enforcing default (15).") self.context_shallow_count = 15 self.allowed_tabs = set(t.strip() for t in os.getenv('LLM_TABS', DEFAULT_TABS).split(',')) server_secret = settings.get('server', {}).get('secret_key', '') self.secret = hashlib.sha256(f"ai_answers_{server_secret}".encode()).hexdigest() self.system_prompt = os.getenv('LLM_SYSTEM_PROMPT', '').strip() def _parse_aux_results(self, raw_results, raw_infoboxes, raw_answers): results = [] limit = self.context_deep_count + self.context_shallow_count for r in raw_results[:limit]: # MainResult (attribute access) and LegacyResult (dict access) if hasattr(r, 'title'): results.append({ 'title': getattr(r, 'title', ''), 'content': getattr(r, 'content', ''), 'url': getattr(r, 'url', ''), 'publishedDate': getattr(r, 'publishedDate', '') }) else: # Legacy dictionary-style access results.append({ 'title': r.get('title', ''), 'content': r.get('content', ''), 'url': r.get('url', ''), 'publishedDate': r.get('publishedDate', '') }) # SearXNG already merges infoboxes by ID, use first infoboxes = [] for ib in raw_infoboxes[:1]: infoboxes.append({ 'name': ib.get('infobox', '') or ib.get('title', ''), 'content': str(ib.get('content') or '')[:2000], 'attributes': ib.get('attributes', []) }) answers = [] for a in list(raw_answers)[:2]: ans_text = "" if hasattr(a, 'answer') and isinstance(getattr(a, 'answer', None), str): ans_text = a.answer elif isinstance(a, dict) and a.get('answer'): ans_text = str(a['answer']) if ans_text and 'id="sxng-stream-box"' not in ans_text and not ans_text.strip().startswith('<'): answers.append(ans_text) return results, infoboxes, answers def init(self, app): @app.route('/ai-auxiliary-search', methods=['POST']) def ai_auxiliary_search(): if not self.api_key: abort(403) data = request.json or {} token = data.get('tk', '') # Token access control try: ts, sig = token.rsplit('.', 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) aux_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For') if aux_ip: logger.debug(f"{PLUGIN_NAME}: /ai-auxiliary-search from proxied IP {aux_ip}") query = data.get('query', '').strip() lang = data.get('lang', 'all') categories = data.get('categories', 'general') offset = data.get('offset', 0) if not query: return jsonify({'results': []}) try: from searx.search import SearchWithPlugins from searx.search.models import SearchQuery from searx.query import RawTextQuery from searx.webadapter import get_engineref_from_category_list preferences = getattr(request, 'preferences', None) disabled_engines = preferences.engines.get_disabled() if preferences else [] rtq = RawTextQuery(query, disabled_engines) if isinstance(categories, str): category_list = [c.strip() for c in categories.split(',') if c.strip()] else: category_list = categories or ['general'] enginerefs = get_engineref_from_category_list(category_list, disabled_engines) sq = SearchQuery( query=rtq.getQuery(), engineref_list=enginerefs, lang=lang, pageno=1, ) search_obj = SearchWithPlugins(sq, request, user_plugins=[]) result_container = search_obj.search() raw_results = result_container.get_ordered_results() raw_infoboxes = getattr(result_container, 'infoboxes', []) raw_answers = getattr(result_container, 'answers', []) results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers) context_str, new_urls = self._assemble_context(results, infoboxes, answers, offset) return jsonify({ 'context': context_str, 'new_urls': new_urls, 'results': results, 'infoboxes': infoboxes, 'answers': answers, 'query': query }) except Exception as e: logger.error(f"{PLUGIN_NAME}: Aux search failed: {e}") return jsonify({'results': [], 'error': 'Search failed'}), 500 @app.route('/ai-models', methods=['GET']) def ai_models(): token = request.args.get('tk', '') models_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For') if models_ip: logger.debug(f"{PLUGIN_NAME}: /ai-models from proxied IP {models_ip}") try: ts, sig = token.rsplit('.', 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) auth_headers = {"Authorization": f"Bearer {self.api_key}"} p = urlparse(self.endpoint_url) base = f"{p.scheme}://{p.netloc}" def fetch_get(start_url): url = start_url for _ in range(5): conn, path = _get_streaming_connection(url) conn.request("GET", path, headers=auth_headers) res = conn.getresponse() if res.status in (301, 302, 307, 308): location = res.getheader('Location', '') res.read(); conn.close() if not location: return None url = location if location.startswith('http') else f"{urlparse(url).scheme}://{urlparse(url).netloc}{location}" continue return res return None for models_url, parse_fn in [ (f"{base}/v1/models", lambda d: [m['id'] for m in d.get('data', [])]), (f"{base}/api/tags", lambda d: [m['name'] for m in d.get('models', [])]), ]: try: res = fetch_get(models_url) if res and res.status == 200: models = parse_fn(json.loads(res.read().decode('utf-8', errors='replace'))) if models: return jsonify({'models': models}) elif res: res.read() except Exception as e: logger.debug(f"{PLUGIN_NAME}: /ai-models attempt {models_url} failed: {e}") return jsonify({'models': [self.model] if self.model else []}) @app.route('/ai-conversation', methods=['GET', 'POST', 'DELETE']) def ai_conversation(): if request.method == 'GET': token = request.args.get('tk', '') else: body = request.json or {} token = body.get('tk', '') try: ts, sig = token.rsplit('.', 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) if request.method == 'GET': session_id = request.args.get('session_id', '') if not session_id: return jsonify({'turns': []}) turns = _load_conversation(session_id) return jsonify({'turns': turns[-10:]}) elif request.method == 'POST': body = request.json or {} session_id = body.get('session_id', '') turns = body.get('turns', []) if session_id: _save_conversation(session_id, turns) return jsonify({'ok': True}) else: # DELETE body = request.json or {} session_id = body.get('session_id', '') if session_id: try: v = _get_valkey() v.delete(f"ai:conv:{session_id}") except Exception as e: logger.debug(f"{PLUGIN_NAME}: conv delete failed: {e}") return jsonify({'ok': True}) @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.rsplit('.', 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) session_id = data.get('session_id', '') prior_conv = _load_conversation(session_id) if session_id else [] if prior_conv: history_lines = [] for turn in prior_conv[-6:]: role = 'User' if turn.get('role') == 'user' else 'Assistant' content = turn.get('content', '')[:500] history_lines.append(f"{role}: {content}") cross_search_history = '\n'.join(history_lines) else: cross_search_history = '' context_text = data.get('context', '') prev_answer = (data.get('prev_answer') or '')[-4000:] req_model = (data.get('model') or '').strip() effective_model = req_model or self.model client_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For') if client_ip: logger.debug(f"{PLUGIN_NAME}: /ai-stream from proxied IP {client_ip}") if not self.api_key: return Response("Missing API key or query", status=400) intent = _detect_intent(q) intent_cfg = INTENT_CONFIGS.get(intent, INTENT_CONFIGS['general']) logger.debug(f"{PLUGIN_NAME}: detected intent '{intent}' for query: {q[:50]}") today = time.strftime("%Y-%m-%d") lang_instruction = f" Respond in {lang}." if lang not in ('all', 'auto') else "" base_sys = self.system_prompt if self.system_prompt else \ "You are a direct, citation-accurate search synthesis engine." SYSTEM = ( f"{base_sys} Today is {today}.{lang_instruction} " "Output only your final answer. Do not output your thinking process, " "reasoning steps, or internal monologue. Begin your response with the " f"direct answer immediately. {intent_cfg['system_suffix']}" ) max_source_idx = 0 if context_text: indices = re.findall(r'\[(\d+)\]', context_text) if indices: max_source_idx = max(map(int, indices)) CORE_RULES = [ "Answer the question directly using the provided context.", "MUST CITE SOURCES by tailing a sentence with [n] or [n,n] etc. If citing general knowledge, use [*].", "Never explain your process. The user expects a direct response.", "Use markdown formatting where it improves clarity: **bold** for key terms, bullet lists for enumerations, numbered lists for steps. Keep formatting minimal and purposeful.", intent_cfg['format'], "If sources and general knowledge are insufficient, respond with 'Insufficient information to answer.'" ] 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 = intent_cfg['task'] grounding = "GROUNDING: KNOWLEDGE GRAPH > DEEP > SHALLOW." if context_text else "GROUNDING: No sources available. Use general knowledge and cite as [*] which means based on general knowledge." history_rule = "HISTORY: Refer to prior exchange for context. Ideally, do not repeat any claims." 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} {context_text or 'None.'} {prev_answer or 'None.'} {cross_search_history or 'None.'} {q} {numbered_instructions} """ job_id = hashlib.sha256(f"{time.time()}{q}".encode()).hexdigest()[:16] payload_dict = { "model": effective_model, "messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}, {"role": "assistant", "content": ""}, ], "stream": True, "max_tokens": self.max_tokens, "temperature": self.temperature, } stream_payload = json.dumps(payload_dict) stream_headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } try: vk = _get_valkey() vk.set(f"ai:job:{job_id}:status", "running", ex=120) except Exception as e: logger.error(f"{PLUGIN_NAME}: Valkey unavailable: {e}", exc_info=True) return jsonify({"error": "Streaming service unavailable (Valkey connection failed)."}), 503 t = threading.Thread( target=stream_to_valkey, args=(job_id, stream_payload, stream_headers, self.endpoint_url, effective_model), daemon=True, ) t.start() if session_id: turns = _load_conversation(session_id) turns.append({'role': 'user', 'content': q, 'ts': int(time.time())}) _save_conversation(session_id, turns) return jsonify({"job_id": job_id}) @app.route('/ai-status/', methods=['GET']) def ai_status(job_id): token = request.args.get('tk', '') try: ts, sig = token.rsplit('.', 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) offset = max(0, int(request.args.get('offset', 0))) chunks_key = f"ai:job:{job_id}:chunks" status_key = f"ai:job:{job_id}:status" try: vk = _get_valkey() status = vk.get(status_key) if status is None: return jsonify({"error": "Job not found or expired"}), 404 raw_chunks = vk.lrange(chunks_key, offset, -1) except Exception as e: logger.error(f"{PLUGIN_NAME}: Valkey error in /ai-status: {e}", exc_info=True) return jsonify({"error": "Stream service temporarily unavailable"}), 503 done = False error = None chunks = [] for chunk in raw_chunks: if chunk == '__DONE__': done = True break elif chunk.startswith('__ERROR__'): error = chunk[9:] done = True break else: chunks.append(chunk) return jsonify({"chunks": chunks, "done": done, "error": error}) return True def _fetch_page_text(self, url: str, timeout: int = 5) -> str: SKIP_DOMAINS = ('youtube.com', 'twitter.com', 'x.com', 'instagram.com', 'facebook.com', 'reddit.com') try: if url.endswith('.pdf'): return '' if any(d in url for d in SKIP_DOMAINS): return '' current_url = url for _ in range(3): # initial request + up to 2 redirects parsed = urlparse(current_url) host = parsed.hostname or '' if not host: return '' port = parsed.port or (443 if parsed.scheme == 'https' else 80) path = (parsed.path or '/') + ('?' + parsed.query if parsed.query else '') if parsed.scheme == 'https': 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=timeout, context=ctx) else: conn = http.client.HTTPConnection(host, port, timeout=timeout) try: conn.request('GET', path, headers={'User-Agent': 'Mozilla/5.0 (compatible; SearXNG-AI/1.0)'}) res = conn.getresponse() if res.status in (301, 302, 303, 307, 308): location = res.getheader('Location', '') res.read() if not location: return '' current_url = location if location.startswith('http') else f"{parsed.scheme}://{parsed.netloc}{location}" continue if res.status != 200: return '' html = res.read(256 * 1024).decode('utf-8', errors='replace') finally: conn.close() html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r'<[^>]+>', '', html) text = (text.replace('&', '&').replace('<', '<').replace('>', '>') .replace('"', '"').replace(''', "'").replace(' ', ' ')) text = re.sub(r'\s+', ' ', text).strip() logger.debug(f"{PLUGIN_NAME}: fetched {len(text)} chars from {url}") return text[:6000] return '' except Exception: return '' def _enrich_results(self, clean_results: list, query: str) -> list: query_tokens = _tokenize(query) enrich_count = min(5, self.context_deep_count + 2) for r in clean_results: r['fetched_content'] = '' r['relevance_score'] = 0.0 futures_map: dict = {} with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: for r in clean_results[:enrich_count]: futures_map[executor.submit(self._fetch_page_text, r.get('url', ''))] = r for future, r in futures_map.items(): try: text = future.result(timeout=4) if not text or len(text) < 100: snippet = r.get('content', '') if snippet: r['relevance_score'] = _tfidf_score(query_tokens, _tokenize(snippet)) continue chunks = _chunk_text(text, chunk_size=512, overlap=64) best_chunk = '' best_score = -1.0 for chunk in chunks: score = _tfidf_score(query_tokens, _tokenize(chunk)) if score > best_score: best_score = score best_chunk = chunk r['fetched_content'] = best_chunk[:800] r['relevance_score'] = best_score logger.debug( f"{PLUGIN_NAME}: [{r.get('url', '')}] " f"score={best_score:.4f} chunks={len(chunks)}" ) except Exception as e: logger.debug(f"{PLUGIN_NAME}: enrich failed for {r.get('url', '')}: {e}") enriched = [r for r in clean_results[:enrich_count] if r.get('relevance_score', 0) > 0] not_enriched = clean_results[enrich_count:] enriched.sort(key=lambda r: r['relevance_score'], reverse=True) reranked = enriched + not_enriched seen_urls = {r.get('url') for r in reranked} for r in clean_results: if r.get('url') not in seen_urls: reranked.append(r) seen_urls.add(r.get('url')) if enriched: logger.debug( f"{PLUGIN_NAME}: reranked {len(enriched)} results, " f"top score={enriched[0]['relevance_score']:.4f}" ) return reranked def _assemble_context(self, clean_results, infoboxes, answers, offset=0) -> tuple[str, list]: """Builds context string from normalized search data. Returns (context_str, urls).""" context_parts = [] result_urls = [] knowledge_graph_lines = [] for ib in infoboxes: ib_name = ib.get('name', '') or ib.get('infobox', '') or ib.get('title', '') ib_content = str(ib.get('content', '')).replace('\n', ' ').strip() if ib_name: parts = [f"INFOBOX [{ib_name}]:"] if ib_content: parts.append(ib_content) for attr in ib.get('attributes', []): attr_label = attr.get('label', '') attr_value = attr.get('value', '') if attr_label and attr_value: parts.append(f" {attr_label}: {attr_value}") knowledge_graph_lines.append(" ".join(parts) if len(parts) == 2 else "\n".join(parts)) for ans_text in answers: if ans_text and not str(ans_text).startswith('<'): knowledge_graph_lines.append(f"ANSWER: {str(ans_text)[:300]}") if knowledge_graph_lines: context_parts.append("KNOWLEDGE GRAPH:\n" + "\n".join(knowledge_graph_lines)) deep_lines = [] for i, r in enumerate(clean_results[:self.context_deep_count]): url = r.get('url', '') result_urls.append(url) domain = urlparse(url).netloc.replace('www.', '') date_str = f" ({r.get('publishedDate')})" if r.get('publishedDate') else "" title = r.get('title', '').replace('\n', ' ').strip() idx = i + 1 + offset fetched_content = r.get('fetched_content', '') if fetched_content: deep_lines.append(f"[{idx}] {domain}{date_str}: {title}: {fetched_content}") else: logger.debug(f"{PLUGIN_NAME}: falling back to snippet for [{idx}] {domain}") content = str(r.get('content', '')).replace('\n', ' ').strip()[:800] deep_lines.append(f"[{idx}] {domain}{date_str}: {title}: {content}") if deep_lines: context_parts.append("DEEP SOURCES:\n" + "\n".join(deep_lines)) if self.context_shallow_count > 0: shallow_lines = [] start_idx = self.context_deep_count end_idx = self.context_deep_count + self.context_shallow_count for i, r in enumerate(clean_results[start_idx:end_idx]): url = r.get('url', '') result_urls.append(url) domain = urlparse(url).netloc.replace('www.', '') title = r.get('title', '').replace('\n', ' ').strip()[:60] idx = i + 1 + start_idx + offset shallow_lines.append(f"[{idx}] {domain}: {title}") if shallow_lines: context_parts.append("SHALLOW SOURCES (headlines):\n" + "\n".join(shallow_lines)) return "\n\n".join(context_parts), result_urls def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults: results = EngineResults() try: if request and hasattr(request, 'headers') and request.headers.get('X-AI-Auxiliary'): return results if request and request.form.get('format', 'html') != 'html': return results if self.question_mark_required and '?' not in search.search_query.query: return results 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() raw_infoboxes = getattr(search.result_container, 'infoboxes', []) raw_answers = getattr(search.result_container, 'answers', []) q_clean = search.search_query.query.strip() clean_results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers) clean_results = self._enrich_results(clean_results, q_clean) context_str, _ = self._assemble_context(clean_results, infoboxes, answers) ts = str(int(time.time())) lang = search.search_query.lang sig = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest() tk = f"{ts}.{sig}" # XSS blocking safe_json = lambda x: json.dumps(x).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026') session_id = request.cookies.get('sxng_ai_session') if not session_id: session_id = hashlib.sha256( f"{time.time()}{os.urandom(16).hex()}".encode() ).hexdigest()[:24] js_session_id = safe_json(session_id) detected_intent = _detect_intent(q_clean) js_intent = safe_json(detected_intent) b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8') total_context_count = self.context_deep_count + self.context_shallow_count raw_urls = [r.get('url', '') for r in clean_results[:total_context_count]] js_q = safe_json(q_clean) js_lang = safe_json(lang) js_urls = safe_json(raw_urls) js_b64_context = safe_json(b64_context) js_tk = safe_json(tk) js_script_root = safe_json((request.script_root if request else '').rstrip('/')) js_model_init = safe_json(self.model) is_interactive = self.interactive interactive_css = INTERACTIVE_CSS if is_interactive else '' interactive_html = INTERACTIVE_HTML if is_interactive else '' interactive_js_init = INTERACTIVE_JS if is_interactive else '' if is_interactive: interactive_js_complete = "footer.style.display = 'flex';" else: interactive_js_complete = '' stream_fn_sig = 'async function startStream(overrideQ = null, prevAnswer = null, auxContext = null)' stream_q = 'overrideQ || q_init' if is_interactive else 'q_init' stream_body = 'prev_answer: prevAnswer' if is_interactive else '' js_code = FRONTEND_JS_TEMPLATE \ .replace("__IS_INTERACTIVE__", 'true' if is_interactive else 'false') \ .replace("__TK__", js_tk) \ .replace("__SCRIPT_ROOT__", js_script_root) \ .replace("__MODEL_INIT__", js_model_init) \ .replace("__SESSION_ID__", js_session_id) \ .replace("__INTENT__", js_intent) \ .replace("__CITATION_HELPER_JS__", CITATION_HELPER_JS) \ .replace("__INTERACTIVE_JS_INIT__", interactive_js_init) \ .replace("__STREAM_FN_SIG__", stream_fn_sig) \ .replace("__STREAM_Q__", stream_q) \ .replace("__STREAM_BODY__", ', ' + stream_body if stream_body else '') \ .replace("__INTERACTIVE_JS_COMPLETE__", interactive_js_complete) \ .replace("__JS_LANG__", js_lang) \ .replace("__JS_URLS__", js_urls) \ .replace("__B64_CONTEXT__", js_b64_context) \ .replace("__JS_Q__", js_q) html_payload = f''' ''' search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload))) except Exception as e: logger.error(f"{PLUGIN_NAME}: {e}") return results