Fixed mayber #3

Merged
TySS-Dev merged 26 commits from testing into main 2026-05-19 06:01:41 -04:00
7 changed files with 1727 additions and 452 deletions
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
*$py.class
venv/
.env
dev/.env
.idea/
.vscode/
.agent/
+33 -3
View File
@@ -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
+39
View File
@@ -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
+1473
View File
File diff suppressed because it is too large Load Diff
+180 -103
View File
@@ -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'''
<article id="sxng-stream-box" class="answer" style="display:none; margin-top: 0; margin-bottom: 0;">
<article id="sxng-stream-box" class="answer" style="display:none; margin: 0; padding: 0;">
<style>
@keyframes sxng-fade-pulse {{
0%, 100% {{ opacity: 0.1; }}
@@ -2078,11 +2155,11 @@ class SXNGPlugin(Plugin):
</style>
<div class="sxng-ai-header">
<span class="sxng-ai-label">
<span style="color:#4a9eff;font-size:1.1em;">✦</span> AI Overview
<span style="color:var(--color-result-link, #4a9eff);font-size:1.1em;">✦</span> AI Overview
</span>
<select id="sxng-model-select" class="sxng-model-select" title="Select model"></select>
</div>
<p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-result-description); font-size: 0.95rem; margin:0;"><span class="sxng-cursor"></span></p>
<p id="sxng-stream-data" style="white-space: pre-wrap; color: var(--color-answer-font, var(--color-result-description, inherit)); font-size: 0.95rem; margin:0;"><span class="sxng-cursor"></span></p>
{interactive_html}
<script>
{js_code}
@@ -2092,4 +2169,4 @@ class SXNGPlugin(Plugin):
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload)))
except Exception as e:
logger.error(f"{PLUGIN_NAME}: {e}")
return results
return results
+1
View File
@@ -1,3 +1,4 @@
flask
flask-babel
certifi
python-dotenv
-346
View File
@@ -1,346 +0,0 @@
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 ollama_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>&#9881; 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)