Updated the demo.py to work with the changes in ai_answers.py
CI Test Guard / validate-code (push) Has been cancelled
CI Test Guard / validate-code (push) Has been cancelled
This commit is contained in:
@@ -8,16 +8,16 @@ A SearXNG plugin that generates local AI overviews powered by Ollama, using sear
|
|||||||
Features:
|
Features:
|
||||||
- token-by-token UI streaming
|
- token-by-token UI streaming
|
||||||
- clickable inline citations
|
- clickable inline citations
|
||||||
- interactive mode to continue summary, ask follow ups, copy, or regenerate
|
- interactive mode: continue summary, ask follow-ups, copy, or regenerate
|
||||||
- simple response mode with no extras
|
- simple response mode with no extras
|
||||||
- internally called low-latency RAG for follow ups (bypasses http loopback)
|
- internally called low-latency RAG for follow-ups (bypasses HTTP loopback)
|
||||||
- native network integration via `searx.network` (respects proxy/SSL settings)
|
- native network integration via `searx.network` (respects proxy/SSL settings)
|
||||||
- stateless conversation persistence/sharability via URL
|
- stateless conversation persistence/shareability via URL hash
|
||||||
|
- model selector in the AI overview widget
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Place `ai_answers.py` into the `searx/plugins` directory of your instance (or mount it in a container) and enable it in `settings.yml`:
|
Place `ai_answers.py` into the `searx/plugins` directory of your SearXNG instance (or mount it in a container) and enable it in `settings.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
plugins:
|
plugins:
|
||||||
@@ -27,51 +27,102 @@ plugins:
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configure via environment variables:
|
Configure via environment variables.
|
||||||
|
|
||||||
### Required
|
### Required
|
||||||
|
|
||||||
- `LLM_URL`: Ollama chat completions endpoint. Default: `http://ollama:11434/v1/chat/completions`
|
| Variable | Description | Default |
|
||||||
- `LLM_MODEL`: Model name as listed in Ollama. Default: `llama3.2`
|
|---|---|---|
|
||||||
|
| `LLM_URL` | Ollama chat completions endpoint | `http://ollama:11434/v1/chat/completions` |
|
||||||
|
| `LLM_MODEL` | Model name as listed in Ollama | `qwen3.5:9b` |
|
||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- `LLM_SYSTEM_PROMPT`: Overrides the system prompt. Default: `You are a direct, citation-accurate search synthesis engine.`
|
| Variable | Description | Default |
|
||||||
- `LLM_MAX_TOKENS`: Default `200`.
|
|---|---|---|
|
||||||
- `LLM_TEMPERATURE`: Default `0.2`.
|
| `LLM_SYSTEM_PROMPT` | Overrides the default system prompt | `You are a direct, citation-accurate search synthesis engine.` |
|
||||||
- `LLM_CONTEXT_DEEP_COUNT`: Results used as context with full snippets. Default `5`.
|
| `LLM_MAX_TOKENS` | Max tokens in the AI response | `200` |
|
||||||
- `LLM_CONTEXT_SHALLOW_COUNT`: Results with headlines only (additional breadth). Default `15`.
|
| `LLM_TEMPERATURE` | Sampling temperature | `0.2` |
|
||||||
- `LLM_TABS`: Tab whitelist, comma delimited. Default `general,science,it,news`.
|
| `LLM_CONTEXT_DEEP_COUNT` | Results used with full snippets | `5` |
|
||||||
- `LLM_INTERACTIVE`: UI mode. Default `true` (interactive: copy, regenerate, follow up). Set to `false` for simple response only.
|
| `LLM_CONTEXT_SHALLOW_COUNT` | Results with headlines only (breadth) | `15` |
|
||||||
- `LLM_QUESTION_MARK_REQUIRED`: Only trigger AI answers when the query contains `?`. Default `false`.
|
| `LLM_TABS` | Comma-delimited tab whitelist | `general,science,it,news` |
|
||||||
|
| `LLM_INTERACTIVE` | Interactive UI mode (copy, regenerate, follow-up) | `true` |
|
||||||
|
| `LLM_QUESTION_MARK_REQUIRED` | Only trigger on queries containing `?` | `false` |
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
1. User performs initial search
|
|
||||||
2. Results return server side
|
1. User performs a search
|
||||||
|
2. Results return server-side
|
||||||
3. `post_search` plugin hook fires
|
3. `post_search` plugin hook fires
|
||||||
4. Token-optimized context extracted from results
|
4. Token-optimized context is extracted from results
|
||||||
5. UI/logic shell injected into the standard results answer object
|
5. UI/logic shell injected into the standard answers object
|
||||||
6. Client-side script calls custom endpoint with a signed token
|
6. Client-side script calls a signed endpoint (`/ai-stream`)
|
||||||
7. Ollama response renders token by token in the UI
|
7. Ollama streams a response token-by-token in the UI
|
||||||
|
|
||||||
## Example
|
## Docker Compose Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
searxng:
|
||||||
|
environment:
|
||||||
|
- LLM_URL=http://ollama:11434/v1/chat/completions
|
||||||
|
- LLM_MODEL=qwen3.5:9b
|
||||||
|
volumes:
|
||||||
|
- ./ai_answers.py:/usr/local/searxng/searx/plugins/ai_answers.py
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama
|
||||||
|
volumes:
|
||||||
|
- ollama_data:/root/.ollama
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote Ollama
|
||||||
|
|
||||||
|
If your Ollama instance is remote or behind a reverse proxy, set `LLM_URL` to the full endpoint and provide an API key if required. The plugin supports Bearer token auth and follows HTTP redirects.
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- LLM_URL=http://ollama:11434/v1/chat/completions
|
- LLM_URL=https://ollama.example.com/v1/chat/completions
|
||||||
- LLM_MODEL=llama3.2
|
- LLM_API_KEY=your-bearer-token
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
## Development — Demo Server
|
||||||
```
|
|
||||||
LLM_URL=http://ollama:11434/v1/chat/completions
|
|
||||||
LLM_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
A standalone Flask demo server is included in `tests/demo.py`. It mocks the SearXNG plugin environment so you can test the full UI without a running SearXNG instance.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install flask flask-babel
|
pip install flask flask-babel certifi
|
||||||
python tests/demo.py # UI demo at localhost:5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://127.0.0.1:5000/](http://127.0.0.1:5000/) in your browser.
|
||||||
|
|
||||||
|
> **Note:** Use `127.0.0.1:5000`, not `localhost:5000` — macOS AirPlay Receiver can occupy the IPv6 loopback on port 5000.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
- Type a query in the search bar and hit **Search** to trigger an AI overview.
|
||||||
|
- Expand **Ollama Configuration** at the top to change the endpoint URL or Bearer token for the current session. Click **Apply** to save and re-run the current query.
|
||||||
|
- The model selector in the AI overview widget (loaded from `/ai-models`) shows all models available on the configured Ollama server and persists your choice in the session URL.
|
||||||
|
|
||||||
|
### Environment Variables (demo)
|
||||||
|
|
||||||
|
The demo reads the same variables as the plugin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_URL=http://localhost:11434/v1/chat/completions \
|
||||||
|
LLM_MODEL=qwen3.5:9b \
|
||||||
|
python tests/demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or export them before running. Any values set in the config panel at runtime take priority for that session.
|
||||||
|
|||||||
+79
-40
@@ -18,14 +18,13 @@ TOKEN_EXPIRY_SEC = 3600
|
|||||||
STREAM_CHUNK_SIZE = 512
|
STREAM_CHUNK_SIZE = 512
|
||||||
STREAM_TIMEOUT_SEC = 60
|
STREAM_TIMEOUT_SEC = 60
|
||||||
|
|
||||||
def _get_streaming_connection(url: str):
|
def _get_streaming_connection(url: str, verify_ssl: bool = True):
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
||||||
path = parsed.path + ('?' + parsed.query if parsed.query else '')
|
path = parsed.path + ('?' + parsed.query if parsed.query else '')
|
||||||
|
|
||||||
verify_ssl = True
|
if verify_ssl and get_network is not None:
|
||||||
if get_network is not None:
|
|
||||||
try:
|
try:
|
||||||
net = get_network()
|
net = get_network()
|
||||||
verify_ssl = getattr(net, 'verify', True)
|
verify_ssl = getattr(net, 'verify', True)
|
||||||
@@ -33,7 +32,14 @@ def _get_streaming_connection(url: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if parsed.scheme == 'https':
|
if parsed.scheme == 'https':
|
||||||
ctx = ssl.create_default_context() if verify_ssl else ssl._create_unverified_context()
|
if not verify_ssl:
|
||||||
|
ctx = ssl._create_unverified_context()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
except ImportError:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
conn = http.client.HTTPSConnection(host, port, timeout=STREAM_TIMEOUT_SEC, context=ctx)
|
conn = http.client.HTTPSConnection(host, port, timeout=STREAM_TIMEOUT_SEC, context=ctx)
|
||||||
else:
|
else:
|
||||||
conn = http.client.HTTPConnection(host, port, timeout=STREAM_TIMEOUT_SEC)
|
conn = http.client.HTTPConnection(host, port, timeout=STREAM_TIMEOUT_SEC)
|
||||||
@@ -450,28 +456,28 @@ INTERACTIVE_JS = r'''
|
|||||||
const _modelsUrl = script_root + '/ai-models?tk=' + encodeURIComponent(tk_init);
|
const _modelsUrl = script_root + '/ai-models?tk=' + encodeURIComponent(tk_init);
|
||||||
console.log('[AI Answers] Fetching models from', _modelsUrl);
|
console.log('[AI Answers] Fetching models from', _modelsUrl);
|
||||||
fetch(_modelsUrl)
|
fetch(_modelsUrl)
|
||||||
.then(r => {
|
.then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
|
||||||
console.log('[AI Answers] /ai-models response status:', r.status);
|
|
||||||
return r.ok ? r.json() : Promise.reject('HTTP ' + r.status);
|
|
||||||
})
|
|
||||||
.then(d => {
|
.then(d => {
|
||||||
console.log('[AI Answers] /ai-models payload:', d);
|
const models = (d && d.models && d.models.length > 0) ? d.models : [model_init];
|
||||||
if (!d || !d.models || d.models.length <= 1) {
|
const _cur = _msel2.value || model_init;
|
||||||
console.log('[AI Answers] Model selector hidden: need 2+ models, got', d && d.models ? d.models.length : 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const _cur = _msel2.value;
|
|
||||||
_msel2.innerHTML = '';
|
_msel2.innerHTML = '';
|
||||||
d.models.forEach(m => {
|
models.forEach(m => {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = m; o.textContent = m;
|
o.value = m; o.textContent = m;
|
||||||
if (m === (_cur || model_init)) o.selected = true;
|
if (m === _cur) o.selected = true;
|
||||||
_msel2.appendChild(o);
|
_msel2.appendChild(o);
|
||||||
});
|
});
|
||||||
document.getElementById('sxng-model-select').style.display = 'inline-block';
|
_msel2.style.display = 'inline-block';
|
||||||
console.log('[AI Answers] Model selector shown with', d.models.length, 'models');
|
|
||||||
})
|
})
|
||||||
.catch(err => { console.warn('[AI Answers] /ai-models fetch failed:', err); });
|
.catch(() => {
|
||||||
|
if (model_init) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = model_init; o.textContent = model_init;
|
||||||
|
o.selected = true;
|
||||||
|
_msel2.appendChild(o);
|
||||||
|
_msel2.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -751,7 +757,7 @@ class SXNGPlugin(Plugin):
|
|||||||
self.endpoint_url = raw_url
|
self.endpoint_url = raw_url
|
||||||
|
|
||||||
self.api_key = 'ollama'
|
self.api_key = 'ollama'
|
||||||
self.model = os.getenv('LLM_MODEL', 'llama3.2').strip()
|
self.model = os.getenv('LLM_MODEL', 'qwen3.5:9b').strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.max_tokens = max(1, int(os.getenv('LLM_MAX_TOKENS', 200)))
|
self.max_tokens = max(1, int(os.getenv('LLM_MAX_TOKENS', 200)))
|
||||||
@@ -913,23 +919,42 @@ class SXNGPlugin(Plugin):
|
|||||||
except (ValueError, KeyError, AttributeError):
|
except (ValueError, KeyError, AttributeError):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
conn = None
|
auth_headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||||
try:
|
p = urlparse(self.endpoint_url)
|
||||||
p = urlparse(self.endpoint_url)
|
base = f"{p.scheme}://{p.netloc}"
|
||||||
tags_url = f"{p.scheme}://{p.netloc}/api/tags"
|
|
||||||
conn, path = _get_streaming_connection(tags_url)
|
def fetch_get(start_url):
|
||||||
conn.request("GET", path)
|
url = start_url
|
||||||
res = conn.getresponse()
|
for _ in range(5):
|
||||||
body = res.read().decode('utf-8', errors='replace')
|
conn, path = _get_streaming_connection(url)
|
||||||
tags_data = json.loads(body)
|
conn.request("GET", path, headers=auth_headers)
|
||||||
models = [m['name'] for m in tags_data.get('models', [])]
|
res = conn.getresponse()
|
||||||
return jsonify({'models': models})
|
if res.status in (301, 302, 307, 308):
|
||||||
except Exception as e:
|
location = res.getheader('Location', '')
|
||||||
logger.error(f"{PLUGIN_NAME}: /ai-models error: {e}", exc_info=True)
|
res.read(); conn.close()
|
||||||
return jsonify({'models': [self.model] if self.model else []})
|
if not location:
|
||||||
finally:
|
return None
|
||||||
if conn:
|
url = location if location.startswith('http') else f"{urlparse(url).scheme}://{urlparse(url).netloc}{location}"
|
||||||
conn.close()
|
continue
|
||||||
|
return res
|
||||||
|
return None
|
||||||
|
|
||||||
|
for models_url, parse_fn in [
|
||||||
|
(f"{base}/v1/models", lambda d: [m['id'] for m in d.get('data', [])]),
|
||||||
|
(f"{base}/api/tags", lambda d: [m['name'] for m in d.get('models', [])]),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
res = fetch_get(models_url)
|
||||||
|
if res and res.status == 200:
|
||||||
|
models = parse_fn(json.loads(res.read().decode('utf-8', errors='replace')))
|
||||||
|
if models:
|
||||||
|
return jsonify({'models': models})
|
||||||
|
elif res:
|
||||||
|
res.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"{PLUGIN_NAME}: /ai-models attempt {models_url} failed: {e}")
|
||||||
|
|
||||||
|
return jsonify({'models': [self.model] if self.model else []})
|
||||||
|
|
||||||
@app.route('/ai-stream', methods=['POST'])
|
@app.route('/ai-stream', methods=['POST'])
|
||||||
def handle_ai_stream():
|
def handle_ai_stream():
|
||||||
@@ -1020,7 +1045,6 @@ class SXNGPlugin(Plugin):
|
|||||||
def call_ollama():
|
def call_ollama():
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn, path = _get_streaming_connection(self.endpoint_url)
|
|
||||||
payload_dict = {
|
payload_dict = {
|
||||||
"model": effective_model,
|
"model": effective_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -1037,8 +1061,23 @@ class SXNGPlugin(Plugin):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
conn.request("POST", path, body=payload.encode('utf-8'), headers=headers)
|
url = self.endpoint_url
|
||||||
res = conn.getresponse()
|
res = None # type: ignore[assignment]
|
||||||
|
for _ in range(3):
|
||||||
|
conn, path = _get_streaming_connection(url)
|
||||||
|
conn.request("POST", path, body=payload.encode('utf-8'), headers=headers)
|
||||||
|
res = conn.getresponse()
|
||||||
|
if res.status in (301, 302, 307, 308):
|
||||||
|
location = res.getheader('Location', '')
|
||||||
|
res.read()
|
||||||
|
conn.close()
|
||||||
|
conn = None
|
||||||
|
if not location:
|
||||||
|
return '', f"Redirect {res.status} with no Location header"
|
||||||
|
url = location if location.startswith('http') else f"{urlparse(url).scheme}://{urlparse(url).netloc}{location}"
|
||||||
|
logger.info(f"{PLUGIN_NAME}: Following redirect to {url}")
|
||||||
|
continue
|
||||||
|
break
|
||||||
if res.status != 200:
|
if res.status != 200:
|
||||||
body = res.read(1024).decode('utf-8', errors='replace')
|
body = res.read(1024).decode('utf-8', errors='replace')
|
||||||
logger.error(f"{PLUGIN_NAME}: Ollama {res.status}: {body}")
|
logger.error(f"{PLUGIN_NAME}: Ollama {res.status}: {body}")
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import logging
|
||||||
|
from types import ModuleType
|
||||||
|
from flask import Flask, request, redirect
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
|
os.environ.setdefault('LLM_URL', 'http://localhost:11434/v1/chat/completions')
|
||||||
|
|
||||||
|
# SearXNG module mocks
|
||||||
|
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
|
||||||
|
|
||||||
|
searx.settings = {'server': {'secret_key': 'demo-secret'}}
|
||||||
|
searx.network = ModuleType("searx.network")
|
||||||
|
|
||||||
|
sys.modules["searx"] = searx
|
||||||
|
sys.modules["searx.plugins"] = searx_plugins
|
||||||
|
sys.modules["searx.result_types"] = searx_results
|
||||||
|
|
||||||
|
# Network module mock
|
||||||
|
searx_network = ModuleType("searx.network")
|
||||||
|
def mock_network_call(method, url, **kwargs):
|
||||||
|
import http.client, ssl, json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
port = parsed.port or (443 if parsed.scheme=='https' else 80)
|
||||||
|
target = f"{parsed.hostname}:{port}"
|
||||||
|
|
||||||
|
if parsed.scheme == 'https':
|
||||||
|
conn = http.client.HTTPSConnection(target, timeout=30, context=ssl.create_default_context())
|
||||||
|
else:
|
||||||
|
conn = http.client.HTTPConnection(target, timeout=30)
|
||||||
|
|
||||||
|
headers = kwargs.get('headers', {})
|
||||||
|
body = None
|
||||||
|
if kwargs.get('json'):
|
||||||
|
body = json.dumps(kwargs['json'])
|
||||||
|
elif kwargs.get('data'):
|
||||||
|
body = kwargs['data']
|
||||||
|
|
||||||
|
path = parsed.path
|
||||||
|
if parsed.query:
|
||||||
|
path += f"?{parsed.query}"
|
||||||
|
|
||||||
|
if kwargs.get('params'):
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
query_str = urlencode(kwargs['params'])
|
||||||
|
if '?' in path:
|
||||||
|
path += f"&{query_str}"
|
||||||
|
else:
|
||||||
|
path += f"?{query_str}"
|
||||||
|
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
return conn.getresponse()
|
||||||
|
|
||||||
|
def mock_stream(method, url, **kwargs):
|
||||||
|
res = mock_network_call(method, url, **kwargs)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, r):
|
||||||
|
self.status_code = r.status
|
||||||
|
self.text = "Mock Response" # Stub
|
||||||
|
self._r = r
|
||||||
|
|
||||||
|
def generator():
|
||||||
|
while True:
|
||||||
|
chunk = res.read(128)
|
||||||
|
if not chunk: break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return MockResponse(res), generator()
|
||||||
|
|
||||||
|
def mock_get(url, **kwargs):
|
||||||
|
import json
|
||||||
|
res = mock_network_call('GET', url, **kwargs)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, r):
|
||||||
|
self.status_code = r.status
|
||||||
|
self._content = r.read()
|
||||||
|
self.text = self._content.decode('utf-8')
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return json.loads(self.text)
|
||||||
|
|
||||||
|
return MockResponse(res)
|
||||||
|
|
||||||
|
searx_network.stream = mock_stream
|
||||||
|
searx_network.get = mock_get
|
||||||
|
sys.modules["searx.network"] = searx_network
|
||||||
|
|
||||||
|
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("/config", methods=["POST"])
|
||||||
|
def update_config():
|
||||||
|
url = request.form.get("url", "").strip()
|
||||||
|
bearer = request.form.get("bearer", "").strip()
|
||||||
|
model = request.form.get("model", "").strip()
|
||||||
|
query = request.form.get("q", "")
|
||||||
|
if url:
|
||||||
|
plugin.endpoint_url = url
|
||||||
|
plugin.api_key = bearer if bearer else "ollama"
|
||||||
|
if model:
|
||||||
|
plugin.model = model
|
||||||
|
redirect_q = f"?q={query}" if query else ""
|
||||||
|
return redirect(f"/{redirect_q}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/search")
|
||||||
|
def mock_search():
|
||||||
|
query = request.args.get("q", "")
|
||||||
|
format_type = request.args.get("format", "html")
|
||||||
|
|
||||||
|
if format_type != "json":
|
||||||
|
return "Demo only supports JSON format", 400
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{"title": f"Result 1 for: {query}", "content": f"This is simulated content about {query}. It contains relevant information.", "url": f"https://example.com/1/{query.replace(' ', '-')}", "publishedDate": "2026-01-18"},
|
||||||
|
{"title": f"Result 2 for: {query}", "content": f"Additional information regarding {query}. More context and details.", "url": f"https://example.com/2/{query.replace(' ', '-')}", "publishedDate": "2026-01-17"},
|
||||||
|
{"title": f"Result 3 for: {query}", "content": f"Further reading on {query}. Expert analysis.", "url": f"https://example.com/3/{query.replace(' ', '-')}", "publishedDate": "2026-01-16"},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": results,
|
||||||
|
"infoboxes": [],
|
||||||
|
"answers": [],
|
||||||
|
"suggestions": [f"{query} explained", f"{query} tutorial"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@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):
|
||||||
|
base_results = [
|
||||||
|
{"title": "Wikipedia", "content": "The sky appears blue due to Rayleigh scattering of sunlight. When sunlight enters the atmosphere, it collides with gas molecules and scatters in all directions. Blue light scatters more than other colors because it travels in shorter waves.", "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. This phenomenon, discovered by Lord Rayleigh in the 1870s, explains why we see a blue sky during the day.", "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 while letting other colors pass through more directly.", "url": "https://physicstoday.org/atmosphere", "publishedDate": "2026-01-01"},
|
||||||
|
{"title": "Scientific American", "content": "At sunset, light travels through more atmosphere, scattering away the blue and leaving reds and oranges.", "url": "https://scientificamerican.com/sunset", "publishedDate": "2025-12-20"},
|
||||||
|
{"title": "National Geographic", "content": "Ocean color also results from light scattering and absorption by water molecules.", "url": "https://nationalgeographic.com/ocean-blue", "publishedDate": "2025-12-15"},
|
||||||
|
]
|
||||||
|
broad_results = [
|
||||||
|
{"title": "MIT OpenCourseWare: Atmospheric Physics", "content": "Course materials.", "url": "https://ocw.mit.edu/physics"},
|
||||||
|
{"title": "NOAA: Understanding the Atmosphere", "content": "Educational resource.", "url": "https://noaa.gov/atmosphere"},
|
||||||
|
{"title": "BBC Science: Why is the sky blue?", "content": "Explainer article.", "url": "https://bbc.com/science/sky"},
|
||||||
|
{"title": "Khan Academy: Light and Color", "content": "Video lesson.", "url": "https://khanacademy.org/light"},
|
||||||
|
{"title": "HowStuffWorks: Rayleigh Scattering", "content": "Detailed explanation.", "url": "https://howstuffworks.com/rayleigh"},
|
||||||
|
{"title": "Physics Stack Exchange: Sky color discussion", "content": "Q&A thread.", "url": "https://physics.stackexchange.com/sky"},
|
||||||
|
{"title": "Quora: Atmospheric optics explained", "content": "Community answers.", "url": "https://quora.com/atmosphere"},
|
||||||
|
]
|
||||||
|
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"}
|
||||||
|
] + broad_results
|
||||||
|
return base_results + broad_results
|
||||||
|
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]
|
||||||
|
|
||||||
|
bearer_display = plugin.api_key if plugin.api_key != "ollama" else ""
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>AI Answers Demo</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #2e3440;
|
||||||
|
color: #eceff4;
|
||||||
|
}}
|
||||||
|
:root {{
|
||||||
|
--color-result-border: #3b4252;
|
||||||
|
--color-result-description: #d8dee9;
|
||||||
|
--color-base-font: #88c0d0;
|
||||||
|
--color-result-link: #81a1c1;
|
||||||
|
}}
|
||||||
|
.meta {{ color: #81a1c1; font-size: 0.9rem; }}
|
||||||
|
hr {{ border-color: #4c566a; }}
|
||||||
|
a {{ color: #88c0d0; }}
|
||||||
|
.config-panel {{
|
||||||
|
background: #3b4252;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}}
|
||||||
|
.config-panel summary {{
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #81a1c1;
|
||||||
|
user-select: none;
|
||||||
|
}}
|
||||||
|
.config-panel summary:hover {{ color: #88c0d0; }}
|
||||||
|
.config-row {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}}
|
||||||
|
.config-row label {{
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #81a1c1;
|
||||||
|
}}
|
||||||
|
.config-row input {{
|
||||||
|
background: #2e3440;
|
||||||
|
border: 1px solid #4c566a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eceff4;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
.config-row input:focus {{ outline: none; border-color: #81a1c1; }}
|
||||||
|
.config-btn {{
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
background: #4c566a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eceff4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
}}
|
||||||
|
.config-btn:hover {{ background: #5e81ac; }}
|
||||||
|
.search-row {{
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}}
|
||||||
|
.search-row input {{
|
||||||
|
flex: 1;
|
||||||
|
background: #3b4252;
|
||||||
|
border: 1px solid #4c566a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eceff4;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
}}
|
||||||
|
.search-row input:focus {{ outline: none; border-color: #81a1c1; }}
|
||||||
|
.search-row button {{
|
||||||
|
background: #5e81ac;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eceff4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
}}
|
||||||
|
.search-row button:hover {{ background: #81a1c1; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<details class="config-panel" {'open' if not bearer_display and 'localhost' in plugin.endpoint_url else ''}>
|
||||||
|
<summary>⚙ Ollama Configuration</summary>
|
||||||
|
<form method="POST" action="/config">
|
||||||
|
<input type="hidden" name="q" value="{query}">
|
||||||
|
<div class="config-row">
|
||||||
|
<label>Endpoint URL</label>
|
||||||
|
<input type="text" name="url" value="{plugin.endpoint_url}" placeholder="http://localhost:11434/v1/chat/completions">
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<label>Bearer Token <span style="opacity:0.5;">(optional)</span></label>
|
||||||
|
<input type="text" name="bearer" value="{bearer_display}" placeholder="Leave empty if not required">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="config-btn">Apply</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form class="search-row" method="GET" action="/">
|
||||||
|
<input type="text" name="q" value="{query}" placeholder="Ask something...">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="meta">Model: <strong>{plugin.model}</strong></p>
|
||||||
|
<hr>
|
||||||
|
{injection_html if injection_html else '<p style="color:#f66;">No response — check your Ollama endpoint and token above.</p>'}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("AI Answers - Demo\n")
|
||||||
|
print(f" Endpoint: {plugin.endpoint_url}")
|
||||||
|
print(f" Model: {plugin.model or 'N/A'}")
|
||||||
|
print(f" Mode: {'interactive' if plugin.interactive else 'simple'}")
|
||||||
|
print(f"\n http://localhost:5000/?q=why+is+the+sky+blue\n")
|
||||||
|
app.run(debug=False, port=5000)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
flask
|
flask
|
||||||
flask-babel
|
flask-babel
|
||||||
|
certifi
|
||||||
|
|||||||
Reference in New Issue
Block a user