diff --git a/.gitignore b/.gitignore index f4c2ae9..b13f288 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *$py.class venv/ .env +dev/.env .idea/ .vscode/ .agent/ \ No newline at end of file diff --git a/README.md b/README.md index 1f7fd14..99209b5 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,44 @@ Configure via environment variables. ## Known Issues -- [ ] When asking a follow up question the previous output disappears -- [ ] Parts of the UI are not theme aware resulting in a unpolished look when not using a dark theme -- [ ] When SearXNG provides a info blob for a search it appears on top of the overview i.e. `Wikipedia` or `Linux` +- [ ] Update README with all updates + +- [x] When asking a follow up question the previous output disappears + +- [x] Parts of the UI are not theme aware resulting in a unpolished look when not using a dark theme + +- [x] When SearXNG provides a info blob for a search it appears on top of the overview i.e. `Wikipedia` or `Linux` For any issues not stated here please create an issue ticket on [Gitea](https://git.tysstech.com/TySS-Dev/ollama-ai-answers-searxng/issues) or [GitHub](https://github.com/TySP-Dev/ollama-ai-answers-searxng/issues) and add the `bug` tag. ## Roadmap +### Dev Server + +- [x] Stream viewer — show tokens arriving in real time in the debug panel as they come out of Valkey, so you can see exactly what the LLM is generating chunk by chunk + +- [x] TF-IDF score visualizer — show a table of which URLs were fetched, their scores, and which chunks were selected for context + +- [ ] Intent detection display — show what intent was detected and which system prompt was used for each query + +- [ ] Saved queries — save/load test queries so you can quickly re-run the same set of searches after making changes to the plugin + +- [ ] A/B model comparison — run the same query against two different models simultaneously and show both responses side by side + +- [ ] Response time breakdown — show how long each phase took: SearXNG fetch, page fetching, TF-IDF scoring, LLM stream start, stream complete + +- [ ] Context inspector — show the full assembled context string that gets sent to the LLM, so you can see exactly what it's working with + +- [ ] Prompt viewer — show the full system prompt + user prompt that gets sent to Ollama + +- [ ] Export button — copy the full context + prompt + response as a JSON blob for bug reports + +- [ ] Per-intent system prompt editor — edit the system prompts for each intent type live without restarting + +- [ ] Token counter — show estimated token count of the context being sent + +### Plugin + - [ ] Working on feature plans ## Architecture diff --git a/dev/.env.example b/dev/.env.example new file mode 100644 index 0000000..af56ba1 --- /dev/null +++ b/dev/.env.example @@ -0,0 +1,39 @@ +# AI Answers Plugin — Dev Server Config +# Copy this to .env and fill in your values +# .env is gitignored and never committed + +# Ollama endpoint (required) +LLM_URL=http://localhost:11434/v1/chat/completions + +# Default model +LLM_MODEL=qwen3.5:9b + +# Max response tokens +LLM_MAX_TOKENS=200 + +# Response temperature (0.0 - 2.0) +LLM_TEMPERATURE=0.2 + +# Bearer token for authenticated LLM endpoints +# Leave empty if no Bearer token is needed for your server +LLM_API_KEY= + +# Live SearXNG instance for real search results +# Leave empty to use mock results +SEARXNG_URL= + +# Valkey for streaming (required) +# Start with: docker run -d --name dev-valkey -p 6379:6379 valkey/valkey:9-alpine +VALKEY_HOST=localhost +VALKEY_PORT=6379 + +# Dev server host and port +DEV_HOST=127.0.0.1 +DEV_PORT=5000 + +# Plugin settings +LLM_INTERACTIVE=true +LLM_QUESTION_MARK_REQUIRED=false +LLM_TABS=general,science,it,news +LLM_CONTEXT_DEEP_COUNT=5 +LLM_CONTEXT_SHALLOW_COUNT=15 diff --git a/dev/dev.py b/dev/dev.py new file mode 100644 index 0000000..3b24c2e --- /dev/null +++ b/dev/dev.py @@ -0,0 +1,1473 @@ +""" +dev.py — AI Answers Plugin Development Server +Provides a full-featured local dev environment for ollama_answers.py. + +Features: + - Mock SearXNG environment so the plugin loads without a real SearXNG install + - Optional live SearXNG backend (set SEARXNG_URL to proxy real searches) + - Debug console with request/response logging + - Plugin API explorer (all routes: /ai-stream, /ai-status, /ai-models, /ai-conversation, /ai-auxiliary-search) + - Dark UI matching SearXNG's default theme with real CSS vars + - Config panel: LLM endpoint, model, token limits, temperature + - Live query testing with streaming output in the browser + +Usage: + cd dev/ + python dev.py + + Or with a live SearXNG backend: + SEARXNG_URL=http://localhost:8080 python dev.py + +Environment variables (all optional): + LLM_URL Ollama endpoint (default: http://localhost:11434/v1/chat/completions) + LLM_MODEL Model name (default: qwen3.5:9b) + LLM_MAX_TOKENS Max tokens (default: 200) + LLM_TEMPERATURE Temperature (default: 0.2) + SEARXNG_URL Live SearXNG URL (default: None, uses mock results) + DEV_PORT Port to listen on (default: 5000) + DEV_HOST Host to bind to (default: 127.0.0.1) +""" + +import sys +import os +import json +import time +import logging +import hashlib +import secrets +import urllib.request +import urllib.parse +from types import ModuleType + +# ── path setup ────────────────────────────────────────────────────────────── +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# ── .env loading ───────────────────────────────────────────────────────────── +try: + from dotenv import load_dotenv + # look for .env in the project root (one level up from dev/) + _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') + if os.path.exists(_env_path): + load_dotenv(_env_path) + print(f" Loaded .env from {_env_path}") +except ImportError: + pass # python-dotenv not installed, ignore + +# ── logging ────────────────────────────────────────────────────────────────── +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s %(levelname)-8s %(name)s: %(message)s', + datefmt='%H:%M:%S' +) +logger = logging.getLogger('dev') + +# ── env defaults ───────────────────────────────────────────────────────────── +os.environ.setdefault('LLM_URL', 'http://localhost:11434/v1/chat/completions') +os.environ.setdefault('LLM_MODEL', 'qwen3.5:9b') + +SEARXNG_URL = os.getenv('SEARXNG_URL', '').rstrip('/') +DEV_PORT = int(os.getenv('DEV_PORT', 5000)) +DEV_HOST = os.getenv('DEV_HOST', '127.0.0.1') + +# ── SearXNG module mocks ───────────────────────────────────────────────────── +searx_mod = ModuleType("searx") +searx_plugins_mod = ModuleType("searx.plugins") +searx_results_mod = ModuleType("searx.result_types") +searx_network_mod = ModuleType("searx.network") + +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 *a, **kw: kw.get('answer', a[0] if a else "") + self._results = [] + def add(self, res): + self._results.append(res) + +searx_plugins_mod.Plugin = _MockPlugin +searx_plugins_mod.PluginInfo = _MockPluginInfo +searx_results_mod.EngineResults = _MockEngineResults +searx_mod.settings = {'server': {'secret_key': 'dev-secret-key'}} + +# network mock — forwards real HTTP so Ollama calls work +import http.client, ssl as _ssl + +def _real_http(method, url, **kwargs): + from urllib.parse import urlparse + p = urlparse(url) + port = p.port or (443 if p.scheme == 'https' else 80) + path = p.path + (f'?{p.query}' if p.query else '') + if p.scheme == 'https': + conn = http.client.HTTPSConnection(p.hostname, port, timeout=30, + context=_ssl.create_default_context()) + else: + conn = http.client.HTTPConnection(p.hostname, port, timeout=30) + headers = kwargs.get('headers', {}) + body = json.dumps(kwargs['json']).encode() if kwargs.get('json') else kwargs.get('data') + if kwargs.get('params'): + qs = urllib.parse.urlencode(kwargs['params']) + path += ('&' if '?' in path else '?') + qs + conn.request(method, path, body=body, headers=headers) + return conn.getresponse() + +class _MockHTTPResponse: + def __init__(self, r): + self.status_code = r.status + self._content = r.read() + self.text = self._content.decode('utf-8', errors='replace') + def json(self): + return json.loads(self.text) + +def _mock_get(url, **kw): + return _MockHTTPResponse(_real_http('GET', url, **kw)) + +def _mock_stream(method, url, **kw): + r = _real_http(method, url, **kw) + class _Resp: + status_code = r.status + text = "streaming" + def _gen(): + while True: + chunk = r.read(256) + if not chunk: break + yield chunk + return _Resp(), _gen() + +searx_network_mod.stream = _mock_stream +searx_network_mod.get = _mock_get + +sys.modules['searx'] = searx_mod +sys.modules['searx.plugins'] = searx_plugins_mod +sys.modules['searx.result_types'] = searx_results_mod +sys.modules['searx.network'] = searx_network_mod + +# ── log capture handler for intent / score extraction ──────────────────────── +class _ScoreCaptureHandler(logging.Handler): + """Captures plugin log messages so dev routes can extract intent/scores.""" + def __init__(self): + super().__init__() + self.records = [] + + def emit(self, record): + self.records.append(self.format(record)) + + def clear(self): + self.records.clear() + +_score_handler = _ScoreCaptureHandler() +_score_handler.setFormatter(logging.Formatter('%(message)s')) +logging.getLogger('ollama_answers').addHandler(_score_handler) + +# ── load plugin ─────────────────────────────────────────────────────────────── +from flask import Flask, request, redirect, jsonify, Response +from flask_babel import Babel +from ollama_answers import SXNGPlugin + +app = Flask(__name__) +babel = Babel(app) + +class _MockCfg: + active = True + +plugin = SXNGPlugin(_MockCfg()) +plugin.init(app) +# Override api_key from env if set +env_key = os.getenv('LLM_API_KEY', '').strip() +if env_key: + plugin.api_key = env_key + +# Verify Valkey is reachable +try: + import valkey as _vk + _vk_host = os.getenv('VALKEY_HOST', 'localhost') + _vk_port = int(os.getenv('VALKEY_PORT', 6379)) + _test_vk = _vk.Valkey(host=_vk_host, port=_vk_port, + socket_connect_timeout=2) + _test_vk.ping() + print(f" Valkey: {_vk_host}:{_vk_port} ✓") +except Exception as e: + print(f""" + ⚠ Valkey not reachable at {_vk_host}:{_vk_port} + Streaming will not work without Valkey. + + Start one with Docker: + docker run -d --name dev-valkey -p 6379:6379 valkey/valkey:9-alpine + + Or set VALKEY_HOST and VALKEY_PORT in your .env file. + Error: {e} +""") + +# ── request/response debug log storage ─────────────────────────────────────── +_DEV_SESSION = secrets.token_hex(12) + +_dev_log = [] # list of {ts, method, path, status, ms, req_body, resp_body} +MAX_LOG = 100 + +@app.before_request +def _log_req(): + request._dev_start = time.time() + request._dev_body = request.get_data(as_text=True) + +@app.after_request +def _log_resp(resp): + if request.path.startswith('/static'): + return resp + ms = round((time.time() - getattr(request, '_dev_start', time.time())) * 1000, 1) + entry = { + 'ts': time.strftime('%H:%M:%S'), + 'method': request.method, + 'path': request.full_path.rstrip('?'), + 'status': resp.status_code, + 'ms': ms, + 'req': getattr(request, '_dev_body', ''), + 'resp': resp.get_data(as_text=True)[:2000], + } + _dev_log.insert(0, entry) + if len(_dev_log) > MAX_LOG: + _dev_log.pop() + return resp + +# ── mock search results ─────────────────────────────────────────────────────── +MOCK_RESULTS = { + 'default': [ + {"title": "Wikipedia — General Knowledge", + "content": "A comprehensive reference covering the query topic in depth with cited sources.", + "url": "https://en.wikipedia.org/wiki/Main_Page", "publishedDate": "2026-01-15"}, + {"title": "NASA Science — Research Article", + "content": "Peer-reviewed research providing scientific context and empirical data on the topic.", + "url": "https://science.nasa.gov/", "publishedDate": "2026-01-10"}, + {"title": "Scientific American — Analysis", + "content": "In-depth analysis covering recent discoveries and expert commentary.", + "url": "https://scientificamerican.com/", "publishedDate": "2026-01-01"}, + {"title": "BBC Science — Overview", + "content": "Accessible explainer covering the fundamentals and current understanding.", + "url": "https://bbc.com/science", "publishedDate": "2025-12-20"}, + {"title": "MIT OpenCourseWare — Educational Resource", + "content": "Course materials and lecture notes providing structured educational content.", + "url": "https://ocw.mit.edu/", "publishedDate": "2025-12-15"}, + ] + [ + {"title": f"Source {i} — Additional Context", + "content": f"Supplementary information and supporting details from source {i}.", + "url": f"https://example.com/source-{i}", "publishedDate": "2025-12-01"} + for i in range(6, 16) + ] +} + +def _get_results(query: str): + """Return real results from SearXNG if configured, else mock results.""" + if SEARXNG_URL: + try: + qs = urllib.parse.urlencode({'q': query, 'format': 'json', 'categories': 'general'}) + url = f"{SEARXNG_URL}/search?{qs}" + with urllib.request.urlopen(url, timeout=8) as r: + data = json.loads(r.read()) + results = data.get('results', []) + logger.info(f"Live SearXNG: {len(results)} results for '{query}'") + return results, data.get('infoboxes', []), data.get('answers', []) + except Exception as e: + logger.warning(f"Live SearXNG failed ({e}), falling back to mock results") + return MOCK_RESULTS['default'], [], [] + +# ── search route used by the demo UI ───────────────────────────────────────── +_last_render = {'html': '', 'query': '', 'scores': [], 'intent': '—', 'summary': ''} + +@app.route('/dev/search') +def dev_search(): + query = request.args.get('q', 'why is the sky blue') + + global _last_render + _last_render['intent'] = '…' + _last_render['query'] = query + + class _MockSearch: + class _MockSearchQuery: + pageno = 1 + lang = 'en-US' + categories = ['general'] + search_query = _MockSearchQuery() + search_query.query = query + + class _MockResultContainer: + def __init__(self, q): + self.answers = set() + self._q = q + self.infoboxes = [] + def get_ordered_results(self): + results, iboxes, _ = _get_results(self._q) + self.infoboxes = iboxes + return results + + result_container = _MockResultContainer(query) + + session_id = secrets.token_hex(12) + + # Clear prior conversation so each search starts fresh + try: + from ollama_answers import _get_valkey + vk = _get_valkey() + vk.delete(f"ai:conv:{session_id}") + except Exception: + pass + + class _MockRequest: + cookies = {'sxng_ai_session': session_id} + headers = {} + args = {} + script_root = '' + + class _MockForm(dict): + def get(self, key, default=None): + return default # always return html format + + form = _MockForm() + + _score_handler.clear() + search = _MockSearch() + try: + plugin.post_search(_MockRequest(), search) + except Exception as e: + logger.error(f"post_search failed: {e}", exc_info=True) + return f"

post_search error: {e}

" + + html_payload = "" + if search.result_container.answers: + raw = list(search.result_container.answers)[0] + # Markup objects need str() to get the HTML + html_payload = str(raw) if raw else "" + logger.info(f"Got AI answer, length: {len(html_payload)}") + if html_payload: + import re as _re + text_preview = _re.sub(r'<[^>]+>', '', html_payload) + text_preview = ' '.join(text_preview.split())[:300] + logger.info(f"AI output preview: {text_preview}") + else: + logger.warning("post_search produced no answers") + + # Parse intent, per-URL TF-IDF scores, and rerank summary from captured log records +# Parse intent, per-URL TF-IDF scores, and rerank summary from captured log records + import re as _re + detected_intent = '—' + + scores = [] + fallback_domains = set() + summary = '' + + for msg in _score_handler.records: + m = _re.match(r'AI Answers: \[(.+?)\] score=([\d.]+) chunks=(\d+)', msg) + + if m: + scores.append({ + 'url': m.group(1), + 'score': float(m.group(2)), + 'chunks': int(m.group(3)), + 'fallback': False + }) + + m = _re.match(r'AI Answers: falling back to snippet for \[\d+\] (.+)', msg) + if m: + fallback_domains.add(m.group(1)) + + m = _re.match(r'AI Answers: reranked (\d+) results, top score=([\d.]+)', msg) + if m: + summary = f"{m.group(1)} reranked · top {float(m.group(2)):.4f}" + + for s in scores: + domain = s['url'].replace('https://', '').replace('http://', '').split('/')[0] + + if domain in fallback_domains: + s['fallback'] = True + + scores.sort(key=lambda s: s['score'], reverse=True) + + # Refresh latest intent from Valkey + try: + from ollama_answers import _get_valkey + vk = _get_valkey() + latest_intent = vk.get("ai:last_intent") + + if latest_intent: + detected_intent = latest_intent + except Exception: + pass + + _last_render['html'] = html_payload + _last_render['query'] = query + _last_render['intent'] = detected_intent + _last_render['scores'] = scores + _last_render['summary'] = summary + + global _last_job + + _last_job['intent'] = detected_intent + _last_job['query'] = query + + logger.warning(f"RETURNING INTENT TO UI: {repr(_last_job.get('intent'))}") + + return jsonify({'ok': True, 'query': query}) + +@app.route('/dev/render') +def dev_render(): + html = _last_render.get('html', '') + return f""" + + + + + +
{html}
+
+ +""" + +# ── dev API log endpoint ────────────────────────────────────────────────────── +_last_job = {'job_id': None, 'intent': None, 'model': None, + 'query': None, 'start_ts': None} + +def _get_last_job_from_log(): + for entry in _dev_log: + if '/ai-stream' in entry.get('path', '') and entry.get('method') == 'POST': + try: + resp = json.loads(entry.get('resp', '{}')) + if resp.get('job_id'): + return resp['job_id'] + except Exception: + pass + return None + +@app.route('/dev/log') +def dev_log(): + return jsonify(_dev_log) + +@app.route('/dev/log/clear', methods=['POST']) +def dev_log_clear(): + _dev_log.clear() + return jsonify({'ok': True}) + +@app.route('/dev/last-job') +def dev_last_job(): + return jsonify(_last_job) + +@app.route('/dev/last-scores') +def dev_last_scores(): + return jsonify({ + 'scores': _last_render.get('scores', []), + 'intent': _last_render.get('intent', '—'), + 'query': _last_render.get('query', ''), + 'summary': _last_render.get('summary', ''), + }) + +@app.route('/dev/stream-watch') +def dev_stream_watch(): + job_id = request.args.get('job_id', '') + if not job_id: + return jsonify({'error': 'no job_id'}), 400 + + def generate(): + import valkey as _vk + host = os.getenv('VALKEY_HOST', 'localhost') + port = int(os.getenv('VALKEY_PORT', 6379)) + v = _vk.Valkey(host=host, port=port, + socket_connect_timeout=2, + decode_responses=True) + offset = 0 + max_polls = 600 + polls = 0 + while polls < max_polls: + polls += 1 + time.sleep(0.1) + try: + chunks = v.lrange(f"ai:job:{job_id}:chunks", offset, -1) + for chunk in chunks: + if chunk == '__DONE__': + yield "data: __DONE__\n\n" + return + if chunk.startswith('__ERROR__'): + yield f"data: __ERROR__{chunk[9:]}\n\n" + return + yield f"data: {json.dumps(chunk)}\n\n" + offset += len(chunks) + except Exception as e: + yield f"data: __ERROR__{e}\n\n" + return + yield "data: __DONE__\n\n" + + return Response(generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + }) + +# ── dev config update ───────────────────────────────────────────────────────── +@app.route('/dev/config', methods=['POST']) +def dev_config(): + global SEARXNG_URL + data = request.json or {} + if 'llm_url' in data: + url = data.get('llm_url', '').strip() + if url: + plugin.endpoint_url = url + os.environ['LLM_URL'] = url + if 'model' in data and data['model']: + plugin.model = data['model'].strip() + os.environ['LLM_MODEL'] = plugin.model + if 'max_tokens' in data: + try: + plugin.max_tokens = max(1, int(data['max_tokens'])) + except (ValueError, TypeError): + pass + if 'temperature' in data: + try: + plugin.temperature = float(data['temperature']) + except (ValueError, TypeError): + pass + if 'api_key' in data: + plugin.api_key = data['api_key'].strip() or 'ollama' + if 'searxng_url' in data: + SEARXNG_URL = data['searxng_url'].strip().rstrip('/') + if 'valkey_host' in data or 'valkey_port' in data: + host = data.get('valkey_host', os.getenv('VALKEY_HOST', 'localhost')).strip() + port = int(data.get('valkey_port', os.getenv('VALKEY_PORT', 6379))) + os.environ['VALKEY_HOST'] = host + os.environ['VALKEY_PORT'] = str(port) + # Reset the valkey connection pool so it picks up new settings + import ollama_answers as _oa + _oa._VALKEY_POOL = None + return jsonify({ + 'llm_url': plugin.endpoint_url, + 'model': plugin.model, + 'max_tokens': plugin.max_tokens, + 'temperature': plugin.temperature, + 'api_key': plugin.api_key, + 'searxng_url': SEARXNG_URL or None, + }) + +@app.route('/dev/config', methods=['GET']) +def dev_config_get(): + return jsonify({ + 'llm_url': plugin.endpoint_url, + 'model': plugin.model, + 'max_tokens': plugin.max_tokens, + 'temperature': plugin.temperature, + 'api_key': plugin.api_key, + 'searxng_url': SEARXNG_URL or None, + 'interactive': plugin.interactive, + 'allowed_tabs': list(plugin.allowed_tabs), + 'valkey_host': os.getenv('VALKEY_HOST', 'localhost'), + 'valkey_port': int(os.getenv('VALKEY_PORT', 6379)), + }) + +# ── main dev UI ─────────────────────────────────────────────────────────────── +@app.route('/') +def index(): + return DEV_HTML + +DEV_HTML = """ + + + + +AI Answers — Dev Server + + + + +
+

✦ AI Answers Dev

+ Mock Results + Ollama: checking… + Valkey: checking… + +
+ +
+
+ +
+
+

Enter a query above and click Search to test the plugin.

+
+ +
+
+ +
+
+
Config
+
Debug Log
+
Stream
+
API Explorer
+
+ + +
+
Plugin Config
+
loading…
+
Model
+
Max Tokens
+
Temperature
+
Interactive
+
Allowed Tabs
+
SearXNG Backendmock
+
Valkey
+ +
Edit Config
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + +
+
+
No requests yet
+
+
+ + +
+
+ Idle + + +
+
+ Shows token chunks arriving from Valkey in real time during streaming. +
+ +
+ Waiting for next search… +
+
+ First token: — + | + Total: — +
+
+ + +
+

Test plugin routes directly. Token is auto-injected.

+ +
+
+ GET + /ai-models + List Ollama models +
+
+ +

+        
+
+ +
+
+ POST + /ai-stream + Start AI stream +
+
+ + +

+        
+
+ +
+
+ GET + /ai-status/{job_id} + Poll stream status +
+
+
+
+ +

+        
+
+ +
+
+ GET + /ai-conversation + Get conversation +
+
+
+ +

+        
+
+ +
+
+ DELETE + /ai-conversation + Clear conversation +
+
+
+ +

+        
+
+ +
+
+
+ + + + +""" + +# ── token endpoint (dev only) ───────────────────────────────────────────────── +@app.route('/dev/token') +def dev_token(): + ts = str(time.time()) + sig = hashlib.sha256(f"{ts}{plugin.secret}".encode()).hexdigest() + return jsonify({'token': f"{ts}.{sig}"}) + +# ── ollama health check endpoint ────────────────────────────────────────────── +@app.route('/dev/valkey-check') +def dev_valkey_check(): + try: + import valkey as _vk + host = os.getenv('VALKEY_HOST', 'localhost') + port = int(os.getenv('VALKEY_PORT', 6379)) + v = _vk.Valkey(host=host, port=port, socket_connect_timeout=2) + v.ping() + return jsonify({'ok': True, 'host': host, 'port': port}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}) + +@app.route('/dev/ollama-check') +def dev_ollama_check(): + try: + p = urllib.parse.urlparse(plugin.endpoint_url) + base = f"{p.scheme}://{p.netloc}" + url = f"{base}/api/tags" + req = urllib.request.Request(url, headers={'Authorization': f'Bearer {plugin.api_key}'}) + with urllib.request.urlopen(req, timeout=3) as r: + data = json.loads(r.read()) + models = len(data.get('models', [])) + return jsonify({'ok': True, 'models': models}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}) + +# ── entry point ─────────────────────────────────────────────────────────────── +if __name__ == '__main__': + print(f""" + ✦ AI Answers Dev Server + ───────────────────────────────────────── + UI: http://{DEV_HOST}:{DEV_PORT} + LLM: {plugin.endpoint_url} + Model: {plugin.model} + Max tokens: {plugin.max_tokens} + Temperature: {plugin.temperature} + SearXNG: {SEARXNG_URL or 'mock results'} + ───────────────────────────────────────── +""") + app.run(host=DEV_HOST, port=DEV_PORT, debug=False) \ No newline at end of file diff --git a/ollama_answers.py b/ollama_answers.py index 83ac20d..0c19211 100644 --- a/ollama_answers.py +++ b/ollama_answers.py @@ -1,12 +1,11 @@ -import json, os, logging, base64, time, hashlib, re, http.client, ssl, concurrent.futures, threading, math +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 -from searx import network try: from searx.network import get_network except ImportError: get_network = None -from flask import Response, request, abort, jsonify +from flask import request, abort, jsonify from searx.plugins import Plugin, PluginInfo from searx.result_types import EngineResults from searx import settings @@ -24,7 +23,6 @@ except ImportError: logger.warning("AI Answers: valkey package not found. Streaming via Valkey unavailable.") TOKEN_EXPIRY_SEC = 3600 -STREAM_CHUNK_SIZE = 512 STREAM_TIMEOUT_SEC = 60 CONV_TTL = 1800 @@ -276,17 +274,17 @@ INTERACTIVE_CSS = ''' width: 32px; height: 32px; padding: 0; - border: none; + border: 1px solid var(--color-result-border, rgba(0,0,0,0.1)); border-radius: 4px; - background: var(--color-sidebar-bg, #424247); - color: var(--color-search-url, #bbb); + 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-search-url, #303033); - color: var(--color-sidebar-bg, #bbb); + 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 { @@ -300,9 +298,9 @@ INTERACTIVE_CSS = ''' .sxng-input { width: 100%; height: -webkit-fill-available; - background: var(--color-sidebar-bg, #424247); - border: none; - color: var(--color-base-font, #cdd6f4); + 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; @@ -311,7 +309,7 @@ INTERACTIVE_CSS = ''' vertical-align: middle; } .sxng-input:focus { outline: none; } - .sxng-input::placeholder { color: var(--color-base-font, #333); opacity: 0.35; } + .sxng-input::placeholder { color: var(--color-base-font, inherit); opacity: 0.4; } .sxng-input-line { position: absolute; bottom: 0; @@ -335,23 +333,24 @@ INTERACTIVE_CSS = ''' 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); + .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: #424247; + background-color: var(--color-base-background-hover, rgba(0,0,0,0.06)); text-overflow: ellipsis; - border-width: 0 2rem 0 0; - border-color: transparent; + 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-search-url, #bbb); + color: var(--color-base-font, inherit); font-size: .9rem; padding: 1px 10px 1px 10px !important; margin: 0; @@ -361,8 +360,7 @@ INTERACTIVE_CSS = ''' vertical-align: middle; } .sxng-model-select:hover { - background-color: #303033; - color: var(--color-search-url, #bbb); + background-color: var(--color-result-border, rgba(0,0,0,0.15)); } .sxng-reasoning { margin: 0.5rem 0; padding: 0.5rem; @@ -385,7 +383,8 @@ INTERACTIVE_CSS = ''' font-size: 0.75em; color: var(--color-result-link, #5e81ac); text-decoration: none; - opacity: 0.75; + opacity: 1; + font-weight: 600; } .sxng-citation-item a:hover { opacity: 1; @@ -395,18 +394,18 @@ INTERACTIVE_CSS = ''' margin-bottom: 0.75rem; padding: 0.5rem; border-left: 2px solid var(--color-result-link, #5e81ac); - opacity: 0.6; + opacity: 0.85; font-size: 0.85em; } .sxng-prior-history summary { cursor: pointer; color: var(--color-result-link, #5e81ac); - font-weight: 600; + font-weight: 700; } .sxng-prior-answer { margin: 0.25rem 0; padding-left: 0.5rem; - color: var(--color-base-font, #cdd6f4); + color: var(--color-base-font, inherit); } .sxng-md-content { line-height: 1.6; @@ -507,7 +506,7 @@ CITATION_HELPER_JS = r''' 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'); @@ -542,7 +541,7 @@ CITATION_HELPER_JS = r''' }); lastIdx = match.index + match[0].length; }); - + if (lastIdx < text.length) { const s = document.createElement('span'); s.className = 'sxng-chunk'; @@ -600,23 +599,6 @@ INTERACTIVE_JS = r''' _ms.appendChild(_o); } } - if (window.getComputedStyle && box) { - try { - const docStyles = getComputedStyle(document.documentElement); - let accent = docStyles.getPropertyValue('--color-result-link').trim(); - if (!accent) { - const a = document.createElement('a'); - document.body.appendChild(a); - accent = getComputedStyle(a).color; - document.body.removeChild(a); - } - if (accent) { - box.style.setProperty('--color-result-link', accent); - box.style.setProperty('--sxng-ai-accent', accent); - } - } catch(e) {} - } - // conversation saved as base64 URL fragment. const updateState = () => { try { @@ -636,13 +618,13 @@ INTERACTIVE_JS = r''' } 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) {} }; @@ -658,17 +640,17 @@ INTERACTIVE_JS = r''' 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') { @@ -686,7 +668,6 @@ INTERACTIVE_JS = r''' } }); box.style.display = 'block'; - if(wrapper) wrapper.style.display = ''; if(footer && is_interactive) footer.style.display = 'flex'; restored = true; } @@ -756,10 +737,10 @@ INTERACTIVE_JS = r''' 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'); @@ -782,7 +763,7 @@ INTERACTIVE_JS = r''' const newCursor = document.createElement('span'); newCursor.className = 'sxng-cursor'; data.appendChild(newCursor); - + const synthesized = synthesizeQuery(q_init, val); let auxContext = null; try { @@ -799,7 +780,7 @@ INTERACTIVE_JS = r''' } } } catch (err) {} - + await startStream(val, currentText, auxContext); updateState(); } else { @@ -871,16 +852,92 @@ FRONTEND_JS_TEMPLATE = r""" const conversation = { originalQuery: q_init, originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))), - originalSources: [...urls], turns: [{role: 'user', content: q_init, ts: Date.now()}] }; const box = document.getElementById('sxng-stream-box'); const data = document.getElementById('sxng-stream-data'); - const wrapper = box.closest('.answer'); - if (wrapper) wrapper.style.display = 'none'; + + (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() { @@ -943,11 +1000,10 @@ FRONTEND_JS_TEMPLATE = r""" console.warn('[AI Answers] Stream already in progress, ignoring duplicate call'); return; } - + isStreaming = true; try { const ctx = auxContext || conversation.originalContext; - if (wrapper) wrapper.style.display = ''; box.style.display = 'block'; const controller = new AbortController(); @@ -1002,6 +1058,11 @@ FRONTEND_JS_TEMPLATE = r""" 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) => { @@ -1009,8 +1070,7 @@ FRONTEND_JS_TEMPLATE = r""" if (force) { const fragment = renderCitations(buffer, urls); - if (cursor) cursor.before(fragment); - else data.appendChild(fragment); + streamContainer.appendChild(fragment); buffer = ''; return; } @@ -1025,12 +1085,12 @@ FRONTEND_JS_TEMPLATE = r""" const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = preText; - cursor.before(s); + streamContainer.appendChild(s); } const citationText = match[0]; const fragment = renderCitations(citationText, urls); - cursor.before(fragment); + streamContainer.appendChild(fragment); buffer = buffer.substring(match.index + match[0].length); } @@ -1041,7 +1101,7 @@ FRONTEND_JS_TEMPLATE = r""" const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = buffer; - cursor.before(s); + streamContainer.appendChild(s); buffer = ''; } } else { @@ -1050,7 +1110,7 @@ FRONTEND_JS_TEMPLATE = r""" const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = safeChunk; - cursor.before(s); + streamContainer.appendChild(s); } buffer = buffer.substring(openIdx); @@ -1058,7 +1118,7 @@ FRONTEND_JS_TEMPLATE = r""" const s = document.createElement('span'); s.className = 'sxng-chunk'; s.textContent = buffer[0]; - cursor.before(s); + streamContainer.appendChild(s); buffer = buffer.substring(1); } } @@ -1120,11 +1180,9 @@ FRONTEND_JS_TEMPLATE = r""" } } + streamContainer.remove(); if (cursor) cursor.remove(); - // Remove only the streamed chunk spans added during this stream - Array.from(data.querySelectorAll('.sxng-chunk')).forEach(node => node.remove()); - const rendered = parseMarkdown(fullText.trim()); const mdDiv = document.createElement('div'); mdDiv.className = 'sxng-md-content'; @@ -1151,13 +1209,13 @@ FRONTEND_JS_TEMPLATE = r""" 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(); @@ -1288,8 +1346,6 @@ INTENT_CONFIGS = { }, } - -import typing if typing.TYPE_CHECKING: from searx.search import SearchWithPlugins from searx.extended_types import SXNG_Request @@ -1379,7 +1435,7 @@ class SXNGPlugin(Plugin): 'content': str(ib.get('content') or '')[:2000], 'attributes': ib.get('attributes', []) }) - + answers = [] for a in list(raw_answers)[:2]: ans_text = "" @@ -1389,7 +1445,7 @@ class SXNGPlugin(Plugin): 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): @@ -1397,10 +1453,10 @@ class SXNGPlugin(Plugin): 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) @@ -1420,13 +1476,13 @@ class SXNGPlugin(Plugin): 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) @@ -1434,7 +1490,7 @@ class SXNGPlugin(Plugin): 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(), @@ -1444,19 +1500,19 @@ class SXNGPlugin(Plugin): ) 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, + 'results': results, 'infoboxes': infoboxes, 'answers': answers, 'query': query @@ -1669,6 +1725,16 @@ class SXNGPlugin(Plugin): job_id = hashlib.sha256(f"{time.time()}{q}".encode()).hexdigest()[:16] + # Persist intent for dev UI + logger.warning(f"INTENT BEFORE PERSIST: {repr(intent)}") + logger.warning(f"JOB_ID BEFORE PERSIST: {repr(job_id)}") + try: + vk = _get_valkey() + vk.setex(f"ai:job:{job_id}:intent", 3600, intent) + logger.debug(f"{PLUGIN_NAME}: persisted intent '{intent}' for job {job_id}") + except Exception: + logger.exception(f"{PLUGIN_NAME}: failed to persist intent") + payload_dict = { "model": effective_model, "messages": [ @@ -1876,12 +1942,12 @@ class SXNGPlugin(Plugin): """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: @@ -1891,16 +1957,16 @@ class SXNGPlugin(Plugin): 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', '') @@ -1916,10 +1982,10 @@ class SXNGPlugin(Plugin): 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 @@ -1931,10 +1997,10 @@ class SXNGPlugin(Plugin): 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: @@ -1958,7 +2024,7 @@ class SXNGPlugin(Plugin): 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) @@ -1981,12 +2047,23 @@ class SXNGPlugin(Plugin): detected_intent = _detect_intent(q_clean) js_intent = safe_json(detected_intent) - + + # Persist intent for dev tooling / UI + try: + vk = _get_valkey() + vk.setex( + f"ai:job:{job_id}:intent", + 1800, + detected_intent + ) + except Exception as e: + logger.debug(f"{PLUGIN_NAME}: failed to persist intent: {e}") + 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) @@ -2028,7 +2105,7 @@ class SXNGPlugin(Plugin): .replace("__JS_Q__", js_q) html_payload = f''' -