This commit is contained in:
+117
-11
@@ -5,7 +5,7 @@ try:
|
|||||||
from searx.network import get_network
|
from searx.network import get_network
|
||||||
except ImportError:
|
except ImportError:
|
||||||
get_network = None
|
get_network = None
|
||||||
from flask import Response, request, abort, jsonify
|
from flask import Response, request, abort, jsonify, stream_with_context
|
||||||
from searx.plugins import Plugin, PluginInfo
|
from searx.plugins import Plugin, PluginInfo
|
||||||
from searx.result_types import EngineResults
|
from searx.result_types import EngineResults
|
||||||
from searx import settings
|
from searx import settings
|
||||||
@@ -164,6 +164,20 @@ INTERACTIVE_CSS = '''
|
|||||||
}
|
}
|
||||||
.sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; }
|
.sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; }
|
||||||
.sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; }
|
.sxng-input-submit svg { width: 18px; height: 18px; fill: currentColor; }
|
||||||
|
.sxng-model-select {
|
||||||
|
background: var(--color-sidebar-bg, var(--color-base-background, #f8f8f8));
|
||||||
|
color: var(--color-base-font, #333);
|
||||||
|
border: 1px solid var(--color-search-url, rgba(0,0,0,0.15));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
max-width: 160px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sxng-model-select:hover { opacity: 1; }
|
||||||
.sxng-reasoning {
|
.sxng-reasoning {
|
||||||
margin: 0.5rem 0; padding: 0.5rem;
|
margin: 0.5rem 0; padding: 0.5rem;
|
||||||
border-left: 2px solid var(--color-result-link, #5e81ac);
|
border-left: 2px solid var(--color-result-link, #5e81ac);
|
||||||
@@ -183,6 +197,7 @@ INTERACTIVE_HTML = '''
|
|||||||
<button class="sxng-btn" id="btn-regen" title="Regenerate answer">
|
<button class="sxng-btn" id="btn-regen" title="Regenerate answer">
|
||||||
<svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4.01 7.58 4.01 12C4.01 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4.01 7.58 4.01 12C4.01 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<select id="sxng-model-select" class="sxng-model-select" title="Select model"></select>
|
||||||
<form id="sxng-action-form" class="sxng-input-wrapper" onsubmit="event.preventDefault();">
|
<form id="sxng-action-form" class="sxng-input-wrapper" onsubmit="event.preventDefault();">
|
||||||
<input type="text" id="sxng-action-input" class="sxng-input" placeholder="Ask..." aria-label="Ask follow-up" autocomplete="off">
|
<input type="text" id="sxng-action-input" class="sxng-input" placeholder="Ask..." aria-label="Ask follow-up" autocomplete="off">
|
||||||
<div class="sxng-input-line"></div>
|
<div class="sxng-input-line"></div>
|
||||||
@@ -249,6 +264,16 @@ CITATION_HELPER_JS = r'''
|
|||||||
INTERACTIVE_JS = r'''
|
INTERACTIVE_JS = r'''
|
||||||
const footer = document.getElementById('sxng-footer');
|
const footer = document.getElementById('sxng-footer');
|
||||||
const input = document.getElementById('sxng-action-input');
|
const input = document.getElementById('sxng-action-input');
|
||||||
|
if (typeof model_init !== 'undefined' && model_init) {
|
||||||
|
const _ms = document.getElementById('sxng-model-select');
|
||||||
|
if (_ms) {
|
||||||
|
const _o = document.createElement('option');
|
||||||
|
_o.value = model_init;
|
||||||
|
_o.textContent = model_init;
|
||||||
|
_o.selected = true;
|
||||||
|
_ms.appendChild(_o);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (window.getComputedStyle && box) {
|
if (window.getComputedStyle && box) {
|
||||||
try {
|
try {
|
||||||
const docStyles = getComputedStyle(document.documentElement);
|
const docStyles = getComputedStyle(document.documentElement);
|
||||||
@@ -454,6 +479,7 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
const b64_init = __B64_CONTEXT__;
|
const b64_init = __B64_CONTEXT__;
|
||||||
const tk_init = __TK__;
|
const tk_init = __TK__;
|
||||||
const script_root = __SCRIPT_ROOT__;
|
const script_root = __SCRIPT_ROOT__;
|
||||||
|
const model_init = __MODEL_INIT__;
|
||||||
const conversation = {
|
const conversation = {
|
||||||
originalQuery: q_init,
|
originalQuery: q_init,
|
||||||
originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))),
|
originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))),
|
||||||
@@ -493,7 +519,8 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
let timeoutId = setTimeout(() => controller.abort(), 60000);
|
let timeoutId = setTimeout(() => controller.abort(), 60000);
|
||||||
const finalQ = __STREAM_Q__;
|
const finalQ = __STREAM_Q__;
|
||||||
|
|
||||||
const bodyObj = { q: finalQ, lang: lang_init, context: ctx, tk: tk_init__STREAM_BODY__ };
|
const _selMdl = (document.getElementById('sxng-model-select') || {value: ''}).value;
|
||||||
|
const bodyObj = { q: finalQ, lang: lang_init, context: ctx, tk: tk_init, model: _selMdl__STREAM_BODY__ };
|
||||||
const res = await fetch(script_root + '/ai-stream', {
|
const res = await fetch(script_root + '/ai-stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -961,6 +988,11 @@ class SXNGPlugin(Plugin):
|
|||||||
abort(403)
|
abort(403)
|
||||||
except (ValueError, KeyError, AttributeError):
|
except (ValueError, KeyError, AttributeError):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
aux_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For')
|
||||||
|
if aux_ip:
|
||||||
|
logger.debug(f"{PLUGIN_NAME}: /ai-auxiliary-search from proxied IP {aux_ip}")
|
||||||
|
|
||||||
query = data.get('query', '').strip()
|
query = data.get('query', '').strip()
|
||||||
lang = data.get('lang', 'all')
|
lang = data.get('lang', 'all')
|
||||||
categories = data.get('categories', 'general')
|
categories = data.get('categories', 'general')
|
||||||
@@ -1013,6 +1045,43 @@ class SXNGPlugin(Plugin):
|
|||||||
logger.error(f"{PLUGIN_NAME}: Aux search failed: {e}")
|
logger.error(f"{PLUGIN_NAME}: Aux search failed: {e}")
|
||||||
return jsonify({'results': [], 'error': 'Search failed'}), 500
|
return jsonify({'results': [], 'error': 'Search failed'}), 500
|
||||||
|
|
||||||
|
@app.route('/ai-models', methods=['GET'])
|
||||||
|
def ai_models():
|
||||||
|
token = request.args.get('tk', '')
|
||||||
|
|
||||||
|
models_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For')
|
||||||
|
if models_ip:
|
||||||
|
logger.debug(f"{PLUGIN_NAME}: /ai-models from proxied IP {models_ip}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ts, sig = token.rsplit('.', 1)
|
||||||
|
expected = hashlib.sha256(f"{ts}{self.secret}".encode()).hexdigest()
|
||||||
|
if sig != expected or (time.time() - float(ts)) > TOKEN_EXPIRY_SEC:
|
||||||
|
abort(403)
|
||||||
|
except (ValueError, KeyError, AttributeError):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if self.provider != 'ollama':
|
||||||
|
return jsonify({'models': [self.model] if self.model else []})
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
p = urlparse(self.endpoint_url)
|
||||||
|
tags_url = f"{p.scheme}://{p.netloc}/api/tags"
|
||||||
|
conn, path = _get_streaming_connection(tags_url)
|
||||||
|
conn.request("GET", path)
|
||||||
|
res = conn.getresponse()
|
||||||
|
body = res.read().decode('utf-8', errors='replace')
|
||||||
|
tags_data = json.loads(body)
|
||||||
|
models = [m['name'] for m in tags_data.get('models', [])]
|
||||||
|
return jsonify({'models': models})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{PLUGIN_NAME}: /ai-models error: {e}", exc_info=True)
|
||||||
|
return jsonify({'models': [self.model] if self.model else []})
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
@app.route('/ai-stream', methods=['POST'])
|
@app.route('/ai-stream', methods=['POST'])
|
||||||
def handle_ai_stream():
|
def handle_ai_stream():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1031,7 +1100,13 @@ class SXNGPlugin(Plugin):
|
|||||||
|
|
||||||
context_text = data.get('context', '')
|
context_text = data.get('context', '')
|
||||||
prev_answer = (data.get('prev_answer') or '')[-4000:]
|
prev_answer = (data.get('prev_answer') or '')[-4000:]
|
||||||
|
req_model = (data.get('model') or '').strip()
|
||||||
|
effective_model = req_model or self.model
|
||||||
|
|
||||||
|
client_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Forwarded-For')
|
||||||
|
if client_ip:
|
||||||
|
logger.debug(f"{PLUGIN_NAME}: /ai-stream from proxied IP {client_ip}")
|
||||||
|
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
return Response("Missing API key or query", status=400)
|
return Response("Missing API key or query", status=400)
|
||||||
|
|
||||||
@@ -1089,6 +1164,7 @@ class SXNGPlugin(Plugin):
|
|||||||
</CORE_DIRECTIVES>"""
|
</CORE_DIRECTIVES>"""
|
||||||
|
|
||||||
def stream_gemini():
|
def stream_gemini():
|
||||||
|
yield ""
|
||||||
if '?' in self.endpoint_url:
|
if '?' in self.endpoint_url:
|
||||||
url = f"{self.endpoint_url}&key={self.api_key}"
|
url = f"{self.endpoint_url}&key={self.api_key}"
|
||||||
else:
|
else:
|
||||||
@@ -1170,22 +1246,26 @@ class SXNGPlugin(Plugin):
|
|||||||
logger.debug(f"{PLUGIN_NAME}: Ignored malformed Gemini chunk. Error: {parse_err}")
|
logger.debug(f"{PLUGIN_NAME}: Ignored malformed Gemini chunk. Error: {parse_err}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{PLUGIN_NAME}: Gemini stream error: {e}")
|
logger.error(f"{PLUGIN_NAME}: Gemini stream error: {e}", exc_info=True)
|
||||||
yield f"\n⚠️ Connection Error: {e}\n"
|
yield f"\n⚠️ Connection Error: {e}\n"
|
||||||
finally:
|
finally:
|
||||||
if conn: conn.close()
|
if conn: conn.close()
|
||||||
|
|
||||||
def stream_openai_compatible():
|
def stream_openai_compatible():
|
||||||
|
yield ""
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn, path = _get_streaming_connection(self.endpoint_url)
|
conn, path = _get_streaming_connection(self.endpoint_url)
|
||||||
payload = json.dumps({
|
payload_dict = {
|
||||||
"model": self.model,
|
"model": effective_model,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"max_tokens": self.max_tokens,
|
"max_tokens": self.max_tokens,
|
||||||
"temperature": self.temperature
|
"temperature": self.temperature
|
||||||
})
|
}
|
||||||
|
if self.provider == 'ollama':
|
||||||
|
payload_dict["think"] = False
|
||||||
|
payload = json.dumps(payload_dict)
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "text/event-stream",
|
"Accept": "text/event-stream",
|
||||||
@@ -1268,7 +1348,7 @@ class SXNGPlugin(Plugin):
|
|||||||
if in_reasoning_block:
|
if in_reasoning_block:
|
||||||
yield "\n</think>\n\n"
|
yield "\n</think>\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{PLUGIN_NAME}: {self.provider} stream error: {e}")
|
logger.error(f"{PLUGIN_NAME}: {self.provider} stream error: {e}", exc_info=True)
|
||||||
yield f"\n⚠️ Connection Error: {e}\n"
|
yield f"\n⚠️ Connection Error: {e}\n"
|
||||||
finally:
|
finally:
|
||||||
if conn: conn.close()
|
if conn: conn.close()
|
||||||
@@ -1288,7 +1368,7 @@ class SXNGPlugin(Plugin):
|
|||||||
finally:
|
finally:
|
||||||
|
|
||||||
self._ollama_unload_model()
|
self._ollama_unload_model()
|
||||||
return Response(generator(), mimetype='text/event-stream', headers={
|
return Response(stream_with_context(generator()), mimetype='text/event-stream', headers={
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
'Connection': 'keep-alive'
|
'Connection': 'keep-alive'
|
||||||
@@ -1400,14 +1480,39 @@ class SXNGPlugin(Plugin):
|
|||||||
js_b64_context = safe_json(b64_context)
|
js_b64_context = safe_json(b64_context)
|
||||||
js_tk = safe_json(tk)
|
js_tk = safe_json(tk)
|
||||||
js_script_root = safe_json((request.script_root if request else '').rstrip('/'))
|
js_script_root = safe_json((request.script_root if request else '').rstrip('/'))
|
||||||
|
js_model_init = safe_json(self.model)
|
||||||
|
|
||||||
is_interactive = self.interactive
|
is_interactive = self.interactive
|
||||||
|
|
||||||
interactive_css = INTERACTIVE_CSS if is_interactive else ''
|
interactive_css = INTERACTIVE_CSS if is_interactive else ''
|
||||||
interactive_html = INTERACTIVE_HTML if is_interactive else ''
|
interactive_html = INTERACTIVE_HTML if is_interactive else ''
|
||||||
interactive_js_init = INTERACTIVE_JS if is_interactive else ''
|
interactive_js_init = INTERACTIVE_JS if is_interactive else ''
|
||||||
|
|
||||||
interactive_js_complete = "footer.style.display = 'flex';" if is_interactive else ''
|
if is_interactive:
|
||||||
|
interactive_js_complete = (
|
||||||
|
"footer.style.display = 'flex';\n"
|
||||||
|
" const _msel2 = document.getElementById('sxng-model-select');\n"
|
||||||
|
" if (_msel2 && !_msel2.dataset.loaded) {\n"
|
||||||
|
" _msel2.dataset.loaded = '1';\n"
|
||||||
|
" fetch(script_root + '/ai-models?tk=' + encodeURIComponent(tk_init))\n"
|
||||||
|
" .then(r => r.ok ? r.json() : null)\n"
|
||||||
|
" .then(d => {\n"
|
||||||
|
" if (!d || !d.models || !d.models.length) return;\n"
|
||||||
|
" const _cur = _msel2.value;\n"
|
||||||
|
" _msel2.innerHTML = '';\n"
|
||||||
|
" d.models.forEach(m => {\n"
|
||||||
|
" const o = document.createElement('option');\n"
|
||||||
|
" o.value = m; o.textContent = m;\n"
|
||||||
|
" if (m === (_cur || model_init)) o.selected = true;\n"
|
||||||
|
" _msel2.appendChild(o);\n"
|
||||||
|
" });\n"
|
||||||
|
" _msel2.style.display = '';\n"
|
||||||
|
" })\n"
|
||||||
|
" .catch(() => {});\n"
|
||||||
|
" }"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interactive_js_complete = ''
|
||||||
stream_fn_sig = 'async function startStream(overrideQ = null, prevAnswer = null, auxContext = null)'
|
stream_fn_sig = 'async function startStream(overrideQ = null, prevAnswer = null, auxContext = null)'
|
||||||
stream_q = 'overrideQ || q_init' if is_interactive else 'q_init'
|
stream_q = 'overrideQ || q_init' if is_interactive else 'q_init'
|
||||||
stream_body = f'''prev_answer: prevAnswer''' if is_interactive else ''
|
stream_body = f'''prev_answer: prevAnswer''' if is_interactive else ''
|
||||||
@@ -1416,6 +1521,7 @@ class SXNGPlugin(Plugin):
|
|||||||
.replace("__IS_INTERACTIVE__", 'true' if is_interactive else 'false') \
|
.replace("__IS_INTERACTIVE__", 'true' if is_interactive else 'false') \
|
||||||
.replace("__TK__", js_tk) \
|
.replace("__TK__", js_tk) \
|
||||||
.replace("__SCRIPT_ROOT__", js_script_root) \
|
.replace("__SCRIPT_ROOT__", js_script_root) \
|
||||||
|
.replace("__MODEL_INIT__", js_model_init) \
|
||||||
.replace("__CITATION_HELPER_JS__", CITATION_HELPER_JS) \
|
.replace("__CITATION_HELPER_JS__", CITATION_HELPER_JS) \
|
||||||
.replace("__INTERACTIVE_JS_INIT__", interactive_js_init) \
|
.replace("__INTERACTIVE_JS_INIT__", interactive_js_init) \
|
||||||
.replace("__STREAM_FN_SIG__", stream_fn_sig) \
|
.replace("__STREAM_FN_SIG__", stream_fn_sig) \
|
||||||
|
|||||||
Reference in New Issue
Block a user