diff --git a/README.md b/README.md index e389325..8abb53a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Does not block result loading time.** -A SearXNG plugin that generates an AI answer using search results as RAG grounding context. Supports Google Gemini and OpenAI-compatible providers (OpenRouter, Ollama, OpenAI API etc.). +A SearXNG plugin that generates AI answers using search results as RAG context. Supports 8 LLM providers. -Features token by token UI updates as response is recieved. +Features token-by-token streaming and clickable inline citations. ## Installation @@ -20,32 +20,74 @@ plugins: Set the following environment variables: -### General +### Required -- `LLM_PROVIDER`: `openrouter` (default) or `gemini`. (openrouter for all OpenAI APIs) -- `RESPONSE_MAX_TOKENS`: Defaults to `500`. -- `RESPONSE_TEMPERATURE`: Defaults to `0.2`. +- `LLM_PROVIDER`: openrouter, openai, ollama, localai, lmstudio, gemini, azure, or huggingface +- `LLM_KEY`: Your API key -### OpenRouter / OpenAI / Ollama -(for any OpenAI compatible API, will revise naming clarity in update soon) -- `OPENROUTER_API_KEY`: Your API key. -- `OPENROUTER_MODEL`: Defaults to `google/gemma-3-27b-it:free`. -- `OPENROUTER_BASE_URL`: Defaults to `openrouter.ai`. (Change to `localhost:11434` for Ollama, or base url of target OpenAI-compatible API). +### Optional -### Google Gemini - -- `GEMINI_API_KEY`: Your Google AI API key. -- `GEMINI_MODEL`: Defaults to `gemma-3-27b-it`. +- `LLM_MODEL`: Model identifier. Defaults vary by provider. +- `LLM_URL`: Custom endpoint URL. Overrides provider preset. +- `LLM_MAX_TOKENS`: Defaults to `500`. +- `LLM_TEMPERATURE`: Defaults to `0.2`. +- `LLM_CONTEXT_COUNT`: Search results to include. Defaults to `5`. +- `LLM_TABS`: Comma-separated tab whitelist. Defaults to general,science,it,news. +- `LLM_STYLE`: UI mode. Set to "simple" for no interactive controls (copy, regenerate, follow up, continue). Defaults to simple. ## How It Works -After search completes, the plugin extracts the top 6 results as context. A client-side script calls the stream endpoint with a signed token. The LLM response streams back. Token by token rendering is soon. +After search completes, the plugin extracts top search results as context. A client-side script calls the stream endpoint with a signed token. The LLM response streams back token by token. -## Ollama (Local) +## Examples +### OpenRouter ``` LLM_PROVIDER=openrouter -OPENROUTER_API_KEY=ollama -OPENROUTER_MODEL=gemma3:27b -OPENROUTER_BASE_URL=localhost:11434 +LLM_KEY=sk-or-xxx +LLM_MODEL=google/gemma-3-27b-it:free +``` + +### Ollama (Local) +``` +LLM_PROVIDER=ollama +LLM_KEY=ollama +LLM_MODEL=llama3.2 +``` + +### LocalAI +``` +LLM_PROVIDER=localai +LLM_KEY=your-key +LLM_MODEL=gpt-4 +LLM_URL=http://localai.lan:8080/v1/chat/completions +``` + +### Gemini +``` +LLM_PROVIDER=gemini +LLM_KEY=AIzaSy... +LLM_MODEL=gemma-3-27b-it +``` + +### Azure +``` +LLM_PROVIDER=azure +LLM_KEY=your-api-key +LLM_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment/chat/completions?api-version=2024-02-01 +``` + +### Hugging Face +``` +LLM_PROVIDER=huggingface +LLM_KEY=hf_xxx +LLM_MODEL=meta-llama/Meta-Llama-3-8B-Instruct +``` + +## Development + +```bash +pip install flask flask-babel python-dotenv +python demo.py # Interactive test server on localhost:5000 +python test.py # One-shot test suite ``` diff --git a/ai_answers.py b/ai_answers.py index 4bbeb3e..2200da6 100644 --- a/ai_answers.py +++ b/ai_answers.py @@ -1,4 +1,5 @@ import json, http.client, ssl, os, logging, base64, time, hashlib +from urllib.parse import urlparse from flask import Response, request, abort from searx.plugins import Plugin, PluginInfo from searx.result_types import EngineResults @@ -7,81 +8,234 @@ from markupsafe import Markup logger = logging.getLogger(__name__) -# Constants -TOKEN_EXPIRY_SEC = 60 +TOKEN_EXPIRY_SEC = 86400 CONNECTION_TIMEOUT_SEC = 30 +PROVIDER_PRESETS = { + 'openai': {'url': 'https://api.openai.com/v1/chat/completions', 'model': 'gpt-4o-mini'}, + 'openrouter': {'url': 'https://openrouter.ai/api/v1/chat/completions', 'model': 'google/gemma-3-27b-it:free'}, + 'ollama': {'url': 'http://localhost:11434/v1/chat/completions', 'model': 'llama3.2'}, + 'localai': {'url': 'http://localhost:8080/v1/chat/completions', 'model': 'gpt-4'}, + 'lmstudio': {'url': 'http://localhost:1234/v1/chat/completions', 'model': 'local-model'}, + 'gemini': {'url': 'https://generativelanguage.googleapis.com', 'model': 'gemma-3-27b-it'}, + 'azure': {'url': None, 'model': 'azure-deployment'}, + 'huggingface': {'url': 'https://api-inference.huggingface.co/models/{model}/v1/chat/completions', 'model': 'meta-llama/Meta-Llama-3-8B-Instruct'} +} + +import typing +if typing.TYPE_CHECKING: + from searx.search import SearchWithPlugins + from searx.extended_types import SXNG_Request + from . import PluginCfg + class SXNGPlugin(Plugin): + """ + AI Answers Plugin for SearXNG. + Injects a real-time streaming answer box synthesized from search results using LLM providers. + Supports OpenAI, OpenRouter, Gemini, Ollama, LocalAI, Azure, and Hugging Face. + """ id = "ai_answers" - def __init__(self, plg_cfg): + def __init__(self, plg_cfg: "PluginCfg"): super().__init__(plg_cfg) self.info = PluginInfo( id=self.id, name=gettext("AI Answers Plugin"), - description=gettext("Live AI search answers using AI providers."), - preference_section="general", + description=gettext("Live AI search answers using LLM providers."), + preference_section="general", ) - self.provider = os.getenv('LLM_PROVIDER', 'openrouter').lower() - self.api_key = os.getenv('OPENROUTER_API_KEY') if self.provider == 'openrouter' else os.getenv('GEMINI_API_KEY') - self.model = os.getenv('GEMINI_MODEL', 'gemma-3-27b-it') if self.provider == 'gemini' else os.getenv('OPENROUTER_MODEL', 'google/gemma-3-27b-it:free') - try: - self.max_tokens = int(os.getenv('RESPONSE_MAX_TOKENS', 500)) - except ValueError: - self.max_tokens = 500 - try: - self.temperature = float(os.getenv('RESPONSE_TEMPERATURE', 0.2)) - except ValueError: - self.temperature = 0.2 - self.base_url = os.getenv('OPENROUTER_BASE_URL', 'openrouter.ai') - # Stable secret for multi-worker environments + self._load_config() + if self.api_key: self.secret = os.getenv('SXNG_LLM_SECRET') or hashlib.sha256(self.api_key.encode()).hexdigest() else: self.secret = os.getenv('SXNG_LLM_SECRET', '') - logger.warning("AI Answers plugin: No API key configured, plugin will be inactive") + logger.warning("AI Answers: No API key configured, plugin inactive") + + def _load_config(self): + self.style = os.getenv('LLM_STYLE', 'interactive') + raw_provider = os.getenv('LLM_PROVIDER', '').lower().strip() + + raw_url = os.getenv('LLM_URL', '').strip() + if not raw_provider and raw_url: + url_lower = raw_url.lower() + if 'openai.com' in url_lower: + raw_provider = 'openai' + elif 'openrouter.ai' in url_lower: + raw_provider = 'openrouter' + elif ':11434' in url_lower: + raw_provider = 'ollama' + elif 'generativelanguage.googleapis.com' in url_lower: + raw_provider = 'gemini' + + if not raw_provider: + logger.debug("AI Answers: No provider configured, plugin inactive") + self.provider = '' + self.model = '' + self.is_gemini = False + self.api_key = '' + return + + self.provider = raw_provider if raw_provider in PROVIDER_PRESETS else 'openai' + self.is_gemini = (self.provider == 'gemini') + preset = PROVIDER_PRESETS[self.provider] + + self.api_key = os.getenv('LLM_KEY', '') + if not self.api_key and self.provider in ('ollama', 'localai', 'lmstudio'): + self.api_key = 'none' + self.api_key = self.api_key.strip() + + self.model = os.getenv('LLM_MODEL', preset['model']).strip() + + try: + self.max_tokens = int(os.getenv('LLM_MAX_TOKENS', 500)) + except ValueError: + self.max_tokens = 500 + try: + self.temperature = float(os.getenv('LLM_TEMPERATURE', 0.2)) + except ValueError: + self.temperature = 0.2 + try: + self.context_count = max(0, int(os.getenv('LLM_CONTEXT_COUNT', 5))) + except ValueError: + self.context_count = 5 + + self.allowed_tabs = set(t.strip() for t in os.getenv('LLM_TABS', 'general,science,it,news').split(',')) + + preset_url = preset['url'] + if preset_url and '{model}' in preset_url: + preset_url = preset_url.format(model=self.model) + self._parse_url(preset_url) + + logger.info(f"AI Answers: {self.provider} @ {self.endpoint_host}") + + def _parse_url(self, default_url): + raw_url = os.getenv('LLM_URL', '').strip() or default_url + if not raw_url.startswith(('http://', 'https://')): + raw_url = f"https://{raw_url}" + + parsed = urlparse(raw_url) + self.endpoint_url = raw_url + self.endpoint_host = parsed.hostname or 'localhost' + self.endpoint_port = parsed.port + self.endpoint_path = parsed.path or '/v1/chat/completions' + if parsed.query: + self.endpoint_path += f"?{parsed.query}" + self.endpoint_ssl = (parsed.scheme == 'https') + + if self.is_gemini: + return + + is_local = self.endpoint_host in ('localhost', '127.0.0.1') or self.endpoint_host.startswith('127.') + if not self.endpoint_ssl and not is_local: + logger.warning(f"AI Answers: HTTP on non-localhost ({self.endpoint_host}). Credentials may be exposed.") + + def _get_connection(self): + proxy_url = os.getenv('HTTPS_PROXY' if self.endpoint_ssl else 'HTTP_PROXY') or os.getenv('https_proxy' if self.endpoint_ssl else 'http_proxy') + + target_host = self.endpoint_host + target_port = self.endpoint_port + target_str = f"{target_host}:{target_port}" if target_port else target_host + + if proxy_url: + p = urlparse(proxy_url) + p_host = p.hostname + p_port = p.port or 8080 + + if p.scheme == 'https': + conn = http.client.HTTPSConnection(p_host, p_port, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context()) + else: + conn = http.client.HTTPConnection(p_host, p_port, timeout=CONNECTION_TIMEOUT_SEC) + + conn.set_tunnel(target_host, target_port) + return conn + + # Direct Connection + if self.endpoint_ssl: + return http.client.HTTPSConnection(target_str, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context()) + return http.client.HTTPConnection(target_str, timeout=CONNECTION_TIMEOUT_SEC) def init(self, app): @app.route('/ai-stream', methods=['POST']) - def g_stream(): + def handle_ai_stream(): data = request.json or {} token = data.get('tk', '') q = data.get('q', '') + lang = data.get('lang', 'all') try: ts, sig = token.split('.', 1) - query_clean = q.strip() - expected = hashlib.sha256(f"{ts}{query_clean}{self.secret}".encode()).hexdigest() + expected = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest() if sig != expected or (time.time() - float(ts)) > TOKEN_EXPIRY_SEC: abort(403) except (ValueError, KeyError, AttributeError): abort(403) context_text = data.get('context', '') - if not self.api_key or not q: - return Response("Error: Missing Key", status=400) + prev_answer = (data.get('prev_answer') or '')[-4000:] + + if not self.api_key: + logger.warning(f"AI Answers: request rejected. Key loaded: {bool(self.api_key)}, Query: {bool(q)}") + return Response("Missing API key or query", status=400) + + today = time.strftime("%Y-%m-%d") + target_words = int(self.max_tokens * 0.2) + lang_instruction = f" Respond in {lang}." if lang not in ('all', 'auto') else "" - prompt = ( - f"SYSTEM: Answer USER QUERY by integrating SEARCH RESULTS with expert knowledge.\n" - f"HIERARCHY: Use RESULTS for facts/data. Use KNOWLEDGE for context/synthesis.\n" - f"CONSTRAINTS: <4 sentences | Dense information | Complete thoughts.\n" - f"FALLBACK: If results are empty, answer from knowledge but note the lack of sources.\n\n" - f"SEARCH RESULTS:\n{context_text}\n\n" - f"USER QUERY: {q}\n\n" - f"ANSWER:" - ) + SYSTEM = f"You are a search synthesis engine. Direct, grounded, citation-accurate. Today is {today}.{lang_instruction}" - def generate_gemini(): - host = "generativelanguage.googleapis.com" - path = f"/v1/models/{self.model}:streamGenerateContent?key={self.api_key}" + CORE_RULES = [ + "DENSITY 4/5: Expert-briefing level. No filler, no transitions. Every sentence = new information.", + f"BREVITY: {target_words} words max. Complete, not verbose.", + "CITATIONS: Cite [n] only for specific facts from sources. Max 3 total. Sentence-end only. Never cite common knowledge.", + "NO HEDGE: State answers confidently. Note uncertainty only if critical.", + ] + + if q == "Continue": + task = "CONTINUE: Pick up exactly where previous answer stopped. No repetition. Seamless flow." + elif prev_answer: + task = "FOLLOW-UP: Address the new question using prior context. Prioritize the new query." + else: + task = "ANSWER FIRST: Lead with the direct answer. No preamble, no context-setting." + + grounding = "GROUNDING: Trust sources for current events. Use knowledge for fundamentals." if context_text else "GROUNDING: No sources available. Use knowledge and note 'based on general knowledge'." + history_rule = "HISTORY: Refer to prior exchange for context. Do not repeat." if prev_answer else None + + instructions = [task] + CORE_RULES + [grounding] + if history_rule: + instructions.append(history_rule) + + numbered_instructions = "\n".join(f"{i+1}. {r}" for i, r in enumerate(instructions)) + prompt = f"""{SYSTEM} + + +{context_text or 'None.'} + + + +{prev_answer or 'None.'} + + +{q} + + +{numbered_instructions} + + +""" + + def stream_gemini(): + path = f"/v1/models/{self.model}:streamGenerateContent" conn = None try: - conn = http.client.HTTPSConnection(host, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context()) - payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": self.max_tokens, "temperature": self.temperature}} - conn.request("POST", path, body=json.dumps(payload), headers={"Content-Type": "application/json"}) + conn = self._get_connection() + + payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": self.max_tokens, "temperature": self.temperature, "stopSequences": [""]}} + headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key} + conn.request("POST", path, body=json.dumps(payload), headers=headers) res = conn.getresponse() if res.status != 200: - logger.error(f"Gemini API Error {res.status}: {res.read().decode('utf-8')}") + logger.error(f"Gemini API {res.status}: {res.read().decode('utf-8')}") return decoder = json.JSONDecoder() @@ -105,48 +259,48 @@ class SXNGPlugin(Plugin): buffer = buffer[idx:] except json.JSONDecodeError: break except Exception as e: - logger.error(f"Gemini Stream Exception: {e}") + logger.error(f"Gemini stream error: {e}") finally: if conn: conn.close() - def generate_openrouter(): + def stream_openai_compatible(): conn = None try: - # Support HTTP for localhost/Ollama - is_local = self.base_url.startswith('localhost') or self.base_url.startswith('127.') - if is_local: - conn = http.client.HTTPConnection(self.base_url, timeout=CONNECTION_TIMEOUT_SEC) - else: - conn = http.client.HTTPSConnection(self.base_url, timeout=CONNECTION_TIMEOUT_SEC, context=ssl.create_default_context()) + conn = self._get_connection() + payload = { "model": self.model, "messages": [{"role": "user", "content": prompt}], "stream": True, "max_tokens": self.max_tokens, - "temperature": self.temperature + "temperature": self.temperature, + "stop": [""] } headers = { - "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://github.com/searxng/searxng", - "X-Title": "SearXNG LLM Plugin" + "X-Title": "SearXNG" } - # Ollama uses /v1/... while OpenRouter uses /api/v1/... - api_path = "/v1/chat/completions" if is_local else "/api/v1/chat/completions" - conn.request("POST", api_path, body=json.dumps(payload), headers=headers) + if self.provider == 'azure': + headers['api-key'] = self.api_key + else: + headers['Authorization'] = f"Bearer {self.api_key}" + + conn.request("POST", self.endpoint_path, body=json.dumps(payload), headers=headers) res = conn.getresponse() if res.status != 200: - logger.error(f"OpenRouter API Error {res.status}: {res.read().decode('utf-8')}") + logger.error(f"{self.provider} API {res.status}: {res.read().decode('utf-8')}") return decoder = json.JSONDecoder() - buffer = "" + buffer = b"" while True: chunk = res.read(128) if not chunk: break - buffer += chunk.decode('utf-8') - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) + buffer += chunk + while b"\n" in buffer: + line_bytes, buffer = buffer.split(b"\n", 1) + line = line_bytes.decode('utf-8', errors='replace') if line.startswith("data: "): data_str = line[6:].strip() if data_str == "[DONE]": return @@ -157,11 +311,11 @@ class SXNGPlugin(Plugin): except json.JSONDecodeError: pass except Exception as e: - logger.error(f"OpenRouter Stream Exception: {e}") + logger.error(f"{self.provider} stream error: {e}") finally: if conn: conn.close() - generator = generate_openrouter if self.provider == 'openrouter' else generate_gemini + generator = stream_gemini if self.is_gemini else stream_openai_compatible return Response(generator(), mimetype='text/event-stream', headers={ 'X-Accel-Buffering': 'no', 'Cache-Control': 'no-cache, no-store', @@ -170,106 +324,460 @@ class SXNGPlugin(Plugin): }) return True - def post_search(self, request, search) -> EngineResults: + def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults: results = EngineResults() try: - if not self.active or not self.api_key or search.search_query.pageno > 1: + current_tabs = set(search.search_query.categories) + if not current_tabs: current_tabs = {'general'} + + if not self.active or not self.api_key or search.search_query.pageno > 1 or not self.allowed_tabs.intersection(current_tabs): return results raw_results = search.result_container.get_ordered_results() - context_list = [f"[{i+1}] {r.get('title')}: {r.get('content')}" for i, r in enumerate(raw_results[:6])] + context_list = [] + for i, r in enumerate(raw_results[:self.context_count]): + domain = urlparse(r.get('url', '')).netloc + date = r.get('publishedDate') + date_str = f" ({date})" if date else "" + title = r.get('title') or "" + context_list.append(f"[{i+1}] {domain}{date_str}: {title}: {str(r.get('content', ''))[:500]}") + context_str = "\n".join(context_list) - # Stateless Handshake + ts = str(int(time.time())) q_clean = search.search_query.query.strip() - sig = hashlib.sha256(f"{ts}{q_clean}{self.secret}".encode()).hexdigest() + lang = search.search_query.lang + sig = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest() tk = f"{ts}.{sig}" - + b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8') js_q = json.dumps(q_clean) + js_lang = json.dumps(lang) + js_urls = json.dumps([r.get('url') for r in raw_results[:self.context_count]]) + + is_interactive = (self.style == 'interactive') + + # Conditional CSS for interactive mode + interactive_css = ''' + @keyframes sxng-fade-in-up { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } + } + .sxng-footer { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + opacity: 0; + animation: sxng-fade-in-up 0.5s ease-out forwards; + } + .sxng-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--color-base-font, #333); + cursor: pointer; + transition: all 0.2s ease; + opacity: 0.6; + } + .sxng-btn:hover { + background: var(--color-base-background-hover, rgba(0,0,0,0.05)); + color: var(--color-result-link, #5e81ac); + opacity: 1; + transform: translateY(-1px); + } + .sxng-btn svg { width: 18px; height: 18px; fill: currentColor; } + .sxng-input-wrapper { + flex-grow: 1; + display: flex; + align-items: center; + margin: 0 0.5rem; + position: relative; + } + .sxng-input { + width: 100%; + background: transparent; + border: none; + color: var(--color-base-font, #333); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + padding: 0.5rem 2.5rem 0.5rem 0; + opacity: 0.8; + transition: opacity 0.2s; + } + .sxng-input:focus { outline: none; opacity: 1; } + .sxng-input::placeholder { color: var(--color-base-font, #333); opacity: 0.35; } + .sxng-input-line { + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 1px; + background: var(--color-result-link, #5e81ac); + transition: width 0.3s ease; + } + .sxng-input:focus + .sxng-input-line { width: 100%; } + .sxng-user-msg { + display: block; + width: fit-content; + max-width: 80%; + margin: 1rem 0 1rem auto; + padding: 0.5rem 0.8rem; + background: var(--color-base-background-hover, rgba(0,0,0,0.05)); + border-radius: 12px 12px 0 12px; + color: var(--color-base-font, #333); + font-size: 0.9rem; + line-height: 1.5; + animation: sxng-fade-in-up 0.3s ease-out forwards; + border: 1px solid var(--color-base-border, rgba(0,0,0,0.1)); + } + .sxng-input-submit { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + padding: 8px; + color: var(--color-base-font, #333); + cursor: pointer; + opacity: 0.3; + transition: all 0.2s ease; + } + .sxng-input-wrapper:focus-within .sxng-input-submit, + .sxng-input-submit:hover { opacity: 1; color: var(--color-result-link, #5e81ac); } + .sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; } +''' if is_interactive else '' + + # Conditional HTML for interactive footer + interactive_html = ''' + +''' if is_interactive else '' + + # Conditional JS for interactive handlers + interactive_js_init = ''' + const footer = document.getElementById('sxng-footer'); + const input = document.getElementById('sxng-action-input'); + + document.getElementById('btn-copy').onclick = async (e) => { + const btn = e.currentTarget; + const originalContent = btn.innerHTML; + const text = Array.from(data.childNodes) + .filter(n => n.nodeType === 3 || n.tagName === 'SPAN') + .map(n => n.textContent) + .join(''); + await navigator.clipboard.writeText(text); + btn.innerHTML = ''; + setTimeout(() => btn.innerHTML = originalContent, 2000); + }; + + document.getElementById('btn-regen').onclick = () => { + data.innerHTML = ''; + footer.style.display = 'none'; + startStream(); + }; + + const handleAction = (e) => { + if (e) e.preventDefault(); + const val = input.value.trim(); + const currentText = Array.from(data.childNodes) + .filter(n => n.nodeType === 3 || n.tagName === 'SPAN') + .map(n => { + if (n.classList && n.classList.contains('sxng-user-msg')) { + return '\\n\\nQ: ' + n.textContent + '\\nA: '; + } + return n.textContent; + }) + .join(''); + input.value = ''; + input.blur(); + footer.style.display = 'none'; + + if (val) { + const cursor = data.querySelector('.sxng-cursor'); + if (cursor) cursor.remove(); + const userMsg = document.createElement('span'); + userMsg.className = 'sxng-user-msg'; + userMsg.textContent = val; + data.appendChild(userMsg); + const newCursor = document.createElement('span'); + newCursor.className = 'sxng-cursor'; + data.appendChild(newCursor); + startStream(val, currentText); + } else { + const cursor = data.querySelector('.sxng-cursor'); + if (cursor) cursor.remove(); + data.appendChild(document.createElement('br')); + data.appendChild(document.createElement('br')); + const newCursor = document.createElement('span'); + newCursor.className = 'sxng-cursor'; + data.appendChild(newCursor); + startStream("Continue", currentText); + } + }; + + document.getElementById('sxng-action-form').onsubmit = handleAction; + input.onfocus = () => { + setTimeout(() => { + input.scrollIntoView({behavior: 'smooth', block: 'center'}); + }, 300); + }; +''' if is_interactive else '' + + interactive_js_complete = "footer.style.display = 'flex';" if is_interactive else '' + + # Streaming function signature differs between modes + stream_fn_sig = 'async function startStream(overrideQ = null, prevAnswer = null)' if is_interactive else 'async function startStream()' + stream_q = 'overrideQ || q_init' if is_interactive else 'q_init' + stream_body = f'''prev_answer: prevAnswer''' if is_interactive else '' html_payload = f''' - + + startStream(); + }})(); + + ''' search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload))) except Exception as e: - logger.error(f"AI Answers plugin error: {e}") + logger.error(f"AI Answers: {e}") return results diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..79f0319 --- /dev/null +++ b/demo.py @@ -0,0 +1,153 @@ +""" +AI Answers Plugin - Interactive Demo Server +Simulates SearXNG environment for local development and testing. + +Usage: python demo.py +Then visit: http://localhost:5000/?q=your+query+here + +Requires: pip install flask flask-babel python-dotenv +""" + +import sys +import os +import logging +from types import ModuleType +from flask import Flask, request +from dotenv import load_dotenv + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +load_dotenv() +os.environ.setdefault('LLM_STYLE', 'interactive') + +# Mock SearXNG modules +searx = ModuleType("searx") +searx_plugins = ModuleType("searx.plugins") +searx_results = ModuleType("searx.result_types") + +class MockPlugin: + def __init__(self, cfg): + self.active = getattr(cfg, 'active', True) + +class MockPluginInfo: + def __init__(self, **kwargs): + self.meta = kwargs + +class MockEngineResults: + def __init__(self): + self.types = ModuleType("types") + self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "") + self._results = [] + + def add(self, res): + self._results.append(res) + +searx_plugins.Plugin = MockPlugin +searx_plugins.PluginInfo = MockPluginInfo +searx_results.EngineResults = MockEngineResults + +sys.modules["searx"] = searx +sys.modules["searx.plugins"] = searx_plugins +sys.modules["searx.result_types"] = searx_results + +from ai_answers import SXNGPlugin +from flask_babel import Babel + +app = Flask(__name__) +babel = Babel(app) + +class MockConfig: + active = True + +plugin = SXNGPlugin(MockConfig()) +plugin.init(app) + +@app.route("/") +def index(): + query = request.args.get("q", "why is the sky blue") + + class MockSearchQuery: + pageno = 1 + lang = 'en' + categories = ['general'] + MockSearchQuery.query = query + + class MockSearch: + search_query = MockSearchQuery() + class MockResultContainer: + def __init__(self): + self.answers = set() + + def get_ordered_results(self): + if 'quantum' in query.lower(): + return [ + {"title": "IBM Quantum", "content": "Quantum computers rely on qubits, which can represent 0, 1, or both via superposition. They solve complex problems faster.", "url": "https://www.ibm.com/quantum", "publishedDate": "2026-01-15"}, + {"title": "Nature Physics", "content": "Entanglement allows qubits to be correlated instantly across distances. This is key for quantum cryptography and teleportation.", "url": "https://nature.com/articles/quantum", "publishedDate": "2026-01-10"}, + {"title": "Wikipedia", "content": "Quantum computing uses quantum mechanics. Major applications include drug discovery and materials science.", "url": "https://en.wikipedia.org/wiki/Quantum_computing", "publishedDate": "2025-12-01"} + ] + return [ + {"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering of sunlight.", "url": "https://en.wikipedia.org/wiki/Rayleigh_scattering", "publishedDate": "2026-01-15"}, + {"title": "NASA Science", "content": "Shorter blue wavelengths scatter more than longer red wavelengths.", "url": "https://science.nasa.gov/blue-sky", "publishedDate": "2026-01-10"}, + {"title": "Physics Today", "content": "The atmosphere acts as a filter, scattering blue light in all directions.", "url": "https://physicstoday.org/atmosphere", "publishedDate": "2026-01-01"} + ] + result_container = MockResultContainer() + + search = MockSearch() + plugin.post_search(None, search) + + injection_html = "" + if search.result_container.answers: + injection_html = list(search.result_container.answers)[0] + + return f""" + + + + + AI Answers Demo + + + +
+

Provider: {plugin.provider or 'Not configured'} | Model: {plugin.model or 'N/A'}

+

Query: {query}

+
+ {injection_html if injection_html else '

Plugin inactive. Set LLM_PROVIDER and LLM_KEY in .env

'} +
+

Try: /?q=what+is+quantum+computing

+ + + """ + +if __name__ == "__main__": + print() + print("=" * 50) + print(" AI Answers Plugin - Demo Server") + print("=" * 50) + print(f" Provider: {plugin.provider or 'NOT SET'}") + print(f" Model: {plugin.model or 'N/A'}") + print(f" Style: {plugin.style}") + print(f" Status: {'Active' if plugin.api_key else 'Inactive (no LLM_KEY)'}") + print("=" * 50) + print(" http://localhost:5000/?q=your+query+here") + print("=" * 50) + print() + app.run(debug=True, port=5000) diff --git a/test.py b/test.py new file mode 100644 index 0000000..a84d529 --- /dev/null +++ b/test.py @@ -0,0 +1,186 @@ +""" +AI Answers Plugin - One-Shot Test +Comprehensive test that outputs everything: config, injection, LLM response. + +Usage: python test.py +Requires: pip install flask flask-babel python-dotenv +""" + +import sys +import os +import re +import time +import logging +from types import ModuleType +from dotenv import load_dotenv + +load_dotenv() + +# Suppress Flask noise during test +logging.getLogger('werkzeug').setLevel(logging.ERROR) +logging.basicConfig(level=logging.INFO, format='%(message)s') + +# Mock SearXNG modules +searx = ModuleType("searx") +searx_plugins = ModuleType("searx.plugins") +searx_results = ModuleType("searx.result_types") + +class MockPlugin: + def __init__(self, cfg): + self.active = getattr(cfg, 'active', True) + +class MockPluginInfo: + def __init__(self, **kwargs): + self.meta = kwargs + +class MockEngineResults: + def __init__(self): + self.types = ModuleType("types") + self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "") + self._results = [] + + def add(self, res): + self._results.append(res) + +searx_plugins.Plugin = MockPlugin +searx_plugins.PluginInfo = MockPluginInfo +searx_results.EngineResults = MockEngineResults + +sys.modules["searx"] = searx +sys.modules["searx.plugins"] = searx_plugins +sys.modules["searx.result_types"] = searx_results + +from flask import Flask +from flask_babel import Babel +from ai_answers import SXNGPlugin + +def run_tests(): + print() + print("=" * 60) + print(" AI Answers Plugin - Comprehensive Test") + print("=" * 60) + + # === CONFIG TEST === + print("\n[1/4] Configuration") + print("-" * 40) + + app = Flask(__name__) + Babel(app) + + class MockConfig: + active = True + + plugin = SXNGPlugin(MockConfig()) + plugin.init(app) + + print(f" Provider: {plugin.provider or 'NOT SET'}") + print(f" Model: {plugin.model or 'N/A'}") + print(f" API Key: {'[OK]' if plugin.api_key else '[MISSING]'}") + print(f" Max Tokens: {getattr(plugin, 'max_tokens', 'N/A')}") + print(f" Temperature: {getattr(plugin, 'temperature', 'N/A')}") + print(f" Context Count: {getattr(plugin, 'context_count', 'N/A')}") + print(f" Allowed Tabs: {getattr(plugin, 'allowed_tabs', 'N/A')}") + + if not plugin.api_key: + print("\n" + "=" * 60) + print(" SKIPPED: No LLM_KEY configured") + print(" Set LLM_PROVIDER and LLM_KEY in .env to run full test") + print("=" * 60) + return False + + # === INJECTION TEST === + print("\n[2/4] HTML Injection") + print("-" * 40) + + class MockSearchQuery: + pageno = 1 + query = "why is the sky blue" + lang = 'en' + categories = ['general'] + + class MockSearch: + search_query = MockSearchQuery() + class MockResultContainer: + def __init__(self): + self.answers = set() + def get_ordered_results(self): + return [ + {"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering.", "url": "https://example.com/1", "publishedDate": "2026-01-15"}, + {"title": "NASA", "content": "Blue wavelengths scatter more than red.", "url": "https://example.com/2", "publishedDate": "2026-01-10"}, + ] + result_container = MockResultContainer() + + search = MockSearch() + plugin.post_search(None, search) + + if not search.result_container.answers: + print(" FAIL: No HTML injected") + return False + + html = str(list(search.result_container.answers)[0]) + + has_box = 'id="sxng-stream-box"' in html + has_endpoint = '/ai-stream' in html + + token_match = re.search(r'const tk = "(.*?)";', html) + has_token = bool(token_match) + + print(f" Stream box: {'[OK]' if has_box else '[FAIL]'}") + print(f" Endpoint ref: {'[OK]' if has_endpoint else '[FAIL]'}") + print(f" Auth token: {'[OK]' if has_token else '[FAIL]'}") + print(f" HTML size: {len(html):,} bytes") + + if not (has_box and has_endpoint and has_token): + print(" FAIL: Missing required elements") + return False + + # === STREAM ENDPOINT TEST === + print("\n[3/4] Stream Endpoint") + print("-" * 40) + + with app.test_client() as client: + payload = { + "q": "why is the sky blue", + "context": "[1] Wikipedia: The sky appears blue due to Rayleigh scattering.", + "lang": "en", + "tk": token_match.group(1) + } + + start = time.time() + response = client.post('/ai-stream', json=payload) + elapsed = time.time() - start + + print(f" Status: {response.status_code}") + print(f" Time: {elapsed:.2f}s") + + if response.status_code != 200: + print(f" FAIL: Expected 200, got {response.status_code}") + return False + + # === LLM RESPONSE TEST === + print("\n[4/4] LLM Response") + print("-" * 40) + + data = response.data.decode('utf-8') + print(f" Bytes: {len(data):,}") + print(f" Words: ~{len(data.split())}") + + if len(data) < 10: + print(" FAIL: Response too short (API error?)") + return False + + print("\n --- Response Preview ---") + preview = data[:500] + ("..." if len(data) > 500 else "") + for line in preview.split('\n'): + print(f" {line}") + print(" --- End Preview ---") + + # === SUMMARY === + print("\n" + "=" * 60) + print(" ALL TESTS PASSED") + print("=" * 60) + return True + +if __name__ == "__main__": + success = run_tests() + sys.exit(0 if success else 1) diff --git a/test_standalone.py b/test_standalone.py deleted file mode 100644 index cd06b01..0000000 --- a/test_standalone.py +++ /dev/null @@ -1,148 +0,0 @@ -import sys -import os -import logging -from types import ModuleType -from flask import Flask, request -from dotenv import load_dotenv - -logging.basicConfig(level=logging.INFO) -load_dotenv() - -searx = ModuleType("searx") -searx_plugins = ModuleType("searx.plugins") -searx_results = ModuleType("searx.result_types") - -class MockPlugin: - def __init__(self, cfg): - self.active = getattr(cfg, 'active', True) - -class MockPluginInfo: - def __init__(self, **kwargs): - self.meta = kwargs - -class MockEngineResults: - def __init__(self): - self.types = ModuleType("types") - self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "") - self._results = [] - - def add(self, res): - self._results.append(res) - -searx_plugins.Plugin = MockPlugin -searx_plugins.PluginInfo = MockPluginInfo -searx_results.EngineResults = MockEngineResults - -sys.modules["searx"] = searx -sys.modules["searx.plugins"] = searx_plugins -sys.modules["searx.result_types"] = searx_results - -from ai_answers import SXNGPlugin -from flask_babel import Babel - -app = Flask(__name__) -babel = Babel(app) - -class MockConfig: - active = True - -plugin = SXNGPlugin(MockConfig()) -plugin.init(app) - -@app.route("/") -def index(): - class MockSearchQuery: - pageno = 1 - query = request.args.get("q", "why is the sky blue") - - class MockSearch: - search_query = MockSearchQuery() - class MockResultContainer: - def __init__(self): - self.answers = set() - - def get_ordered_results(self): - return [ - {"title": "Fact About Sky", "content": "The sky is blue because of Rayleigh scattering."}, - {"title": "Atmosphere Info", "content": "The atmosphere scatters shorter blue wavelengths more than red ones."}, - {"title": "NASA Science", "content": "Sunlight reaches Earth's atmosphere and is scattered in all directions by gases."} - ] - result_container = MockResultContainer() - - search = MockSearch() - plugin.post_search(None, search) - - injection_html = "" - if search.result_container.answers: - injection_html = list(search.result_container.answers)[0] - - return f""" - - - - - Plugin Test - - - -

LLM Plugin Test

-

Provider: {plugin.provider} | Model: {plugin.model}

-

Testing query: {MockSearch.search_query.query}

-
- {injection_html} - - - """ - -import unittest - -class PluginTestCase(unittest.TestCase): - def setUp(self): - self.app = app.test_client() - self.app.testing = True - - def test_html_injection(self): - response = self.app.get('/') - content = response.data.decode('utf-8') - self.assertIn('