diff --git a/ai_answers.py b/ai_answers.py index 718c01a..3433de3 100644 --- a/ai_answers.py +++ b/ai_answers.py @@ -4,7 +4,7 @@ from searx import network try: from searx.network import get_network except ImportError: - get_network = None # Graceful fallback for test/demo environments + get_network = None from flask import Response, request, abort, jsonify from searx.plugins import Plugin, PluginInfo from searx.result_types import EngineResults @@ -15,7 +15,7 @@ from markupsafe import Markup logger = logging.getLogger(__name__) TOKEN_EXPIRY_SEC = 3600 -STREAM_CHUNK_SIZE = 256 +STREAM_CHUNK_SIZE = 512 STREAM_TIMEOUT_SEC = 60 def _get_streaming_connection(url: str): @@ -204,7 +204,6 @@ CITATION_HELPER_JS = r''' if (match.index > lastIdx) { const s = document.createElement('span'); s.className = 'sxng-chunk'; - // Preserve whitespace by not trimming s.textContent = text.substring(lastIdx, match.index); fragment.appendChild(s); } @@ -250,9 +249,6 @@ CITATION_HELPER_JS = r''' INTERACTIVE_JS = r''' const footer = document.getElementById('sxng-footer'); const input = document.getElementById('sxng-action-input'); - // Closure inheritance: box, data, conversation references injected from outer scope. - - // Dynamic theme propagation: extract and bind host CSS variables for UI cohesion. if (window.getComputedStyle && box) { try { const docStyles = getComputedStyle(document.documentElement); @@ -270,17 +266,32 @@ INTERACTIVE_JS = r''' } catch(e) {} } - // Stateless persistence: encode conversation matrix as base64 URL fragment. + // conversation saved as base64 URL fragment. const updateState = () => { try { - const state = { + let state = { t: conversation.turns.map(t => ({ r: t.role === 'user' ? 'u' : 'a', c: t.content.replace(/\s+/g, ' ').trim() })), u: urls }; - const b64 = btoa(encodeURIComponent(JSON.stringify(state)).replace(/%([0-9A-F]{2})/g, (m,p)=>String.fromCharCode('0x'+p))); + 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) {} }; @@ -288,7 +299,8 @@ INTERACTIVE_JS = r''' if (location.hash.includes('ai=')) { try { const b64 = location.hash.split('ai=')[1]; - const json = decodeURIComponent(atob(b64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')); + 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 @@ -302,7 +314,6 @@ INTERACTIVE_JS = r''' ts: 0 })); - // Citation rendering proxy const injectCitations = (text) => { return renderCitations(text, urls); }; @@ -320,7 +331,6 @@ INTERACTIVE_JS = r''' data.appendChild(clr); } } else { - // Execute citation routing for synthesized payload data.appendChild(injectCitations(turn.content)); } }); @@ -343,10 +353,26 @@ INTERACTIVE_JS = r''' setTimeout(() => btn.innerHTML = originalContent, 2000); }; - document.getElementById('btn-regen').onclick = () => { + document.getElementById('btn-regen').onclick = async () => { data.innerHTML = ''; footer.style.display = 'none'; - startStream(); + + 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 handleAction = async (e) => { @@ -417,13 +443,6 @@ INTERACTIVE_JS = r''' input.scrollIntoView({behavior: 'smooth', block: 'center'}); }, 300); }; - - const _origStream = startStream; - startStream = async function(...args) { - if (args.length === 0 && restored) return; - await _origStream.apply(this, args); - if (args.length === 0) updateState(); - }; ''' FRONTEND_JS_TEMPLATE = r""" @@ -453,7 +472,6 @@ FRONTEND_JS_TEMPLATE = r""" __INTERACTIVE_JS_INIT__ function synthesizeQuery(original, followup) { - // Strip deterministic NLP prefixes to isolate primary entities 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(); @@ -502,8 +520,6 @@ FRONTEND_JS_TEMPLATE = r""" } let started = false; - let pendingSpace = ''; - let lastScrollKick = 0; let collectedResponse = ''; let isThinking = false, thoughtDiv = null; @@ -581,12 +597,10 @@ FRONTEND_JS_TEMPLATE = r""" streamBuffer += chunk; - // Truncation suspension: prevent evaluation of fragmented SGML tags at chunk boundaries. if (streamBuffer.match(/<\/?(?:t(?:h(?:i(?:n(?:k)?)?)?)?)?$/)) { continue; } - // Deterministic tag extraction: mitigate infinite recursion on malformed stream boundaries. while (true) { const openIdx = streamBuffer.indexOf(''); const closeIdx = streamBuffer.indexOf(''); @@ -621,7 +635,6 @@ FRONTEND_JS_TEMPLATE = r""" streamBuffer = streamBuffer.substring(openIdx + 7); } else { - // Recover from hallucinated tag boundaries without blocking execution. streamBuffer = streamBuffer.replace('', ''); } } else { @@ -631,13 +644,11 @@ FRONTEND_JS_TEMPLATE = r""" isThinking = false; streamBuffer = streamBuffer.substring(closeIdx + 8); } else { - // Drop anomalous nested tag states. streamBuffer = streamBuffer.replace('', ''); } } } - // Evaluate remainder of validated buffer if (streamBuffer.length > 0) { if (isThinking && thoughtDiv) { thoughtDiv.textContent += streamBuffer; @@ -653,22 +664,13 @@ FRONTEND_JS_TEMPLATE = r""" buffer += streamBuffer; flushBuffer(false); } - // Guarantee absolute isolation between reasoning output and presentation payload. collectedResponse += streamBuffer; } - streamBuffer = ''; // Flush consumed buffer chunk - } - - const now = Date.now(); - if (now - lastScrollKick > 500) { - lastScrollKick = now; - void window.getComputedStyle(data).opacity; + streamBuffer = ''; } } - // Reconcile and flush suspended artifacts trailing an abruptly terminated stream. if (streamBuffer.length > 0) { - // Strip invalid partial SGML fragments. streamBuffer = streamBuffer.replace(/<\/?(?:t(?:h(?:i(?:n(?:k)?)?)?)?)?$/, ''); if (streamBuffer.length > 0) { if (isThinking && thoughtDiv) { @@ -680,12 +682,10 @@ FRONTEND_JS_TEMPLATE = r""" } } - // Finalize remaining character outputs. flushBuffer(true); if (cursor) cursor.remove(); - // Dom-tree cleanup: trim residual whitespace nodes. let last = data.lastChild; while (last) { if (last.textContent && last.textContent.trim().length === 0) { @@ -719,6 +719,11 @@ FRONTEND_JS_TEMPLATE = r""" if (collectedResponse) { conversation.turns.push({role: 'assistant', content: collectedResponse.trim(), ts: Date.now()}); } + + // Save state if this was an initial generation or a regeneration + if (arguments.length === 0 && typeof updateState === 'function') { + updateState(); + } } catch (e) { console.error('[AI Answers] Fatal stream exception:', e); @@ -737,19 +742,10 @@ FRONTEND_JS_TEMPLATE = r""" data.appendChild(errSpan); } } finally { - // Deallocate stream lock state unconditionally. isStreaming = false; } } - // Initialize background connection warmup execution. - fetch(script_root + '/ai-stream', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({warmup: true}), - keepalive: true - }).catch(() => {}); - if (!restored) startStream(); })(); """ @@ -829,7 +825,6 @@ class SXNGPlugin(Plugin): elif 'huggingface.co' in url_lower: raw_provider = 'huggingface' else: - # fallback to OpenAI-compatible raw_provider = 'openai' logger.info(f"{PLUGIN_NAME}: Using OpenAI-compatible mode for custom URL") @@ -906,7 +901,7 @@ class SXNGPlugin(Plugin): results = [] limit = self.context_deep_count + self.context_shallow_count for r in raw_results[:limit]: - # Handle both MainResult (attribute access) and LegacyResult (dict access) + # MainResult (attribute access) and LegacyResult (dict access) if hasattr(r, 'title'): results.append({ 'title': getattr(r, 'title', ''), @@ -923,12 +918,12 @@ class SXNGPlugin(Plugin): 'publishedDate': r.get('publishedDate', '') }) - # SearXNG already merges infoboxes by ID - take first with full content + # 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': ib.get('content', '')[:2000], + 'content': str(ib.get('content') or '')[:2000], 'attributes': ib.get('attributes', []) }) @@ -958,7 +953,7 @@ class SXNGPlugin(Plugin): data = request.json or {} token = data.get('tk', '') - # Cryptographic Access Control + # Token access control try: ts, sig = token.rsplit('.', 1) expected = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest() @@ -1014,55 +1009,13 @@ class SXNGPlugin(Plugin): 'query': query }) - except ImportError: - try: - search_url = f'{request.url_root}search' - params = { - 'q': query, - 'format': 'json', - 'categories': categories, - 'language': lang - } - - headers = { - 'X-AI-Auxiliary': '1', - 'Accept-Language': request.headers.get('Accept-Language', '') - } - - - res = network.get(search_url, params=params, headers=headers, timeout=2) - search_data = res.json() - - - - results, infoboxes, answers = self._parse_aux_results( - search_data.get('results', []), - search_data.get('infoboxes', []), - search_data.get('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}: Auxiliary search HTTP fallback failed: {e}") - return jsonify({'results': [], 'error': str(e)}), 500 except Exception as e: - logger.error(f"{PLUGIN_NAME}: Auxiliary search loopback failed: {e}") - return jsonify({'results': [], 'error': str(e)}), 500 + logger.error(f"{PLUGIN_NAME}: Aux search failed: {e}") + return jsonify({'results': [], 'error': 'Search failed'}), 500 @app.route('/ai-stream', methods=['POST']) def handle_ai_stream(): data = request.json or {} - if data.get('warmup'): - return Response('', status=204) token = data.get('tk', '') q = data.get('q', '') @@ -1145,7 +1098,7 @@ class SXNGPlugin(Plugin): try: conn, path = _get_streaming_connection(url) payload = json.dumps({"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": min(self.max_tokens * 4, 8192), "temperature": self.temperature}}) - conn.request("POST", path, body=payload, headers={"Content-Type": "application/json"}) + conn.request("POST", path, body=payload.encode('utf-8'), headers={"Content-Type": "application/json"}) res = conn.getresponse() if res.status != 200: @@ -1177,17 +1130,48 @@ class SXNGPlugin(Plugin): obj, idx = decoder.raw_decode(buffer) items = obj if isinstance(obj, list) else [obj] for item in items: - candidates = item.get('candidates', []) - if candidates: - content = candidates[0].get('content', {}) - parts = content.get('parts', []) - if parts: - text = parts[0].get('text', '') - if text: yield text + if not isinstance(item, dict): + continue + + if 'promptFeedback' in item and item['promptFeedback'].get('blockReason'): + yield f"\n⚠️ Gemini blocked prompt. Reason: {item['promptFeedback']['blockReason']}\n" + return + + candidates = item.get('candidates') + if not isinstance(candidates, list) or len(candidates) == 0: + continue + + first_candidate = candidates[0] + if not isinstance(first_candidate, dict): + continue + + if first_candidate.get('finishReason') == 'SAFETY': + yield "\n⚠️ Gemini stopped generation due to safety filters.\n" + return + + content = first_candidate.get('content') + if not isinstance(content, dict): + continue + + parts = content.get('parts') + if not isinstance(parts, list) or len(parts) == 0: + continue + + first_part = parts[0] + if isinstance(first_part, dict): + text = first_part.get('text') + if text and isinstance(text, str): + yield text + buffer = buffer[idx:] - except json.JSONDecodeError: break + except json.JSONDecodeError: + break + except Exception as parse_err: + logger.debug(f"{PLUGIN_NAME}: Ignored malformed Gemini chunk. Error: {parse_err}") + break except Exception as e: logger.error(f"{PLUGIN_NAME}: Gemini stream error: {e}") + yield f"\n⚠️ Connection Error: {e}\n" finally: if conn: conn.close() @@ -1204,6 +1188,7 @@ class SXNGPlugin(Plugin): }) headers = { "Content-Type": "application/json", + "Accept": "text/event-stream", "HTTP-Referer": "https://github.com/searxng/searxng", "X-Title": "SearXNG" } @@ -1211,7 +1196,7 @@ class SXNGPlugin(Plugin): headers['api-key'] = self.api_key else: headers['Authorization'] = f"Bearer {self.api_key}" - conn.request("POST", path, body=payload, headers=headers) + conn.request("POST", path, body=payload.encode('utf-8'), headers=headers) res = conn.getresponse() if res.status != 200: @@ -1221,11 +1206,9 @@ class SXNGPlugin(Plugin): return decoder = json.JSONDecoder() - tokens_yielded = 0 in_reasoning_block = False while True: - # Use readline() to unblock SSE streaming immediately line_bytes = res.readline() if not line_bytes: break @@ -1241,32 +1224,52 @@ class SXNGPlugin(Plugin): return try: obj, _ = decoder.raw_decode(data_str) - choices = obj.get("choices", []) - choice = choices[0] if choices else {} - delta = choice.get("delta", {}) if isinstance(choice, dict) else {} - reasoning = delta.get("reasoning_content", "") - content = delta.get("content", "") + if not isinstance(obj, dict): + continue - if reasoning: + # Catch upstream errors + if "error" in obj: + err_msg = obj["error"].get("message", str(obj["error"])) if isinstance(obj["error"], dict) else str(obj["error"]) + yield f"\n⚠️ API Error: {err_msg}\n" + return + + choices = obj.get("choices") + if not isinstance(choices, list) or len(choices) == 0: + continue + + choice = choices[0] + if not isinstance(choice, dict): + continue + + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + + reasoning = delta.get("reasoning_content") + content = delta.get("content") + + if reasoning and isinstance(reasoning, str): if not in_reasoning_block: yield "\n" in_reasoning_block = True yield reasoning - tokens_yielded += 1 - if content: + if content and isinstance(content, str): if in_reasoning_block: yield "\n\n\n" in_reasoning_block = False yield content - tokens_yielded += 1 except json.JSONDecodeError: pass + except Exception as parse_err: + logger.debug(f"{PLUGIN_NAME}: Ignored malformed OpenAI chunk. Error: {parse_err}") + pass if in_reasoning_block: yield "\n\n\n" except Exception as e: logger.error(f"{PLUGIN_NAME}: {self.provider} stream error: {e}") + yield f"\n⚠️ Connection Error: {e}\n" finally: if conn: conn.close() @@ -1288,9 +1291,7 @@ class SXNGPlugin(Plugin): return Response(generator(), mimetype='text/event-stream', headers={ 'X-Accel-Buffering': 'no', 'Cache-Control': 'no-cache, no-store', - 'Connection': 'keep-alive', - 'Transfer-Encoding': 'chunked', - 'Content-Encoding': 'identity', + 'Connection': 'keep-alive' }) return True @@ -1337,7 +1338,6 @@ class SXNGPlugin(Plugin): if deep_lines: context_parts.append("DEEP SOURCES:\n" + "\n".join(deep_lines)) - # Low-latency headline heuristics if self.context_shallow_count > 0: shallow_lines = [] start_idx = self.context_deep_count @@ -1377,7 +1377,6 @@ class SXNGPlugin(Plugin): raw_infoboxes = getattr(search.result_container, 'infoboxes', []) raw_answers = getattr(search.result_container, 'answers', []) - # Normalize for unified context assembly clean_results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers) context_str, _ = self._assemble_context(clean_results, infoboxes, answers) @@ -1387,13 +1386,12 @@ class SXNGPlugin(Plugin): sig = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest() tk = f"{ts}.{sig}" - # XSS & Syntax Prevention: Safely serialize data for inline