Compare commits
36 Commits
904cf945a2
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
| ad8f1397bc | |||
| bda9e5a462 | |||
| f471140bd6 | |||
| 68ff90b655 | |||
| 6daf947d00 | |||
| af3e74c92a | |||
| 7e06e07e4f | |||
| 8b7c8f7df8 | |||
| 93d263cdc3 | |||
| b914d13d4e | |||
| eee0fd8709 | |||
| baec4522cf | |||
| 1702d9cd20 | |||
| 2a5a501a96 | |||
| 64aa62f5e0 | |||
| 378a485ba7 | |||
| f66264b92a | |||
| ff3b75d129 | |||
| 08d4915d4a | |||
| ce42f9a652 | |||
| 9e784c8b8b | |||
| 8e7752c2de | |||
| 78941479db | |||
| 83494bb023 | |||
| e46c752aec | |||
| 541d98f7f1 | |||
| 4c749b825c | |||
| 23ecac6afa | |||
| 4b36a261c4 | |||
| eeac7fcd88 | |||
| 1c3824b7a4 | |||
| a7c031d27b | |||
| 5e2b2a246f | |||
| ffad0de8ae | |||
| 3dffeb384b | |||
| 85d1481bd9 |
@@ -3,6 +3,7 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
venv/
|
venv/
|
||||||
.env
|
.env
|
||||||
|
dev/.env
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.agent/
|
.agent/
|
||||||
@@ -10,44 +10,50 @@
|
|||||||
|
|
||||||
A SearXNG plugin that generates local AI overviews powered by Ollama, using search results as RAG context.
|
A SearXNG plugin that generates local AI overviews powered by Ollama, using search results as RAG context.
|
||||||
|
|
||||||
Features:
|
## Features:
|
||||||
- Token-by-token UI streaming
|
|
||||||
- Clickable inline citations
|
- Inline numbered citations
|
||||||
- Interactive mode: continue summary, ask follow-ups, copy, or regenerate
|
- Interactive mode - Continue summary, ask follow-ups, copy, or regenerate
|
||||||
- Simple response mode with no extras
|
- Overview of ranked results with prompts based on detected query intent:
|
||||||
- Internally called low-latency RAG for follow-ups (bypasses HTTP loopback)
|
- `How To` `Technical` `Factual` `Comparison` `Opinion` `Current` `Local` `Geneal`
|
||||||
- Native network integration via `searx.network` (respects proxy/SSL settings)
|
- Internally called RAG for follow-ups
|
||||||
- Stateless conversation persistence/shareability via URL hash
|
- Native network integration via `searx.network`
|
||||||
- Model selector in the AI overview widget
|
- Stateless conversation presistence/shareability via URL hash
|
||||||
- Does not slow down result loading
|
- Ollama model selector
|
||||||
- One file install
|
- Feeds fetched results to Ollama without slowing down SearXNG results
|
||||||
- Real-time streaming via Valkey — responses stream token by token using a background thread + Valkey job queue, working around granian's broken generator support for true streaming feel
|
- Real-time streaming via Valkey (No waiting for a completed response)
|
||||||
- TF-IDF result reranking — fetched page content is scored against the query using BM25-style TF-IDF before being sent to Ollama, surfacing the most relevant sources first
|
- TF-IDF result ranking before being sent to Ollama
|
||||||
- Smart chunking — pages are split into 512-token overlapping segments and the highest-scoring chunk per page is selected for context
|
- Smart chunking - Pages are split into 512-token segments and highest-scoring chunk per page used for context
|
||||||
- Intent detection — queries are automatically classified into 8 intent types (factual, howto, technical, comparison, opinion, current, local, general) with tailored system prompts per type
|
- Conversation memory - 30-minute cross-search conversation history via Valkey for follow-up questions
|
||||||
- Conversation memory — 30-minute cross-search conversation history stored in Valkey, so follow-up questions work even after navigating to a new search
|
- Markdown support
|
||||||
- Markdown rendering — AI responses render bold, italic, lists, headers, and inline code natively in the result box
|
- Intent emoji badge showing what intent prompt was used
|
||||||
- Intent emoji badge — a small emoji appears next to "AI Overview" indicating the detected query type
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download the plugin:
|
1. Download the plugin:
|
||||||
|
|
||||||
|
### Main repo (Gitea)
|
||||||
```bash
|
```bash
|
||||||
curl -o ollama_answers.py https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/master/ollama_answers.py
|
curl -o ollama_answers.py https://git.tysstech.com/TySS-Dev/ollama-ai-answers-searxng/raw/branch/main/ollama_answers.py
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Copy to your SearXNG plugins directory:
|
### Mirror repo (Github):
|
||||||
```bash
|
```bash
|
||||||
cp ollama_answers.py ~/searxng/plugins/ollama_answers.py
|
curl -o ollama_answers.py https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/main/ollama_answers.py
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Add the volume mount to your `docker-compose.yml` under the searxng service:
|
3. Copy to your SearXNG plugins directory:
|
||||||
|
```bash
|
||||||
|
cp ollama_answers.py path_to/searxng/plugins/ollama_answers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add the volume mount to your `docker-compose.yml` under the searxng service:
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z
|
- ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Add environment variables to `docker-compose.yml`:
|
5. Add environment variables to `docker-compose.yml`:
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- LLM_URL=http://ollama:11434/v1/chat/completions
|
- LLM_URL=http://ollama:11434/v1/chat/completions
|
||||||
@@ -55,14 +61,14 @@ Features:
|
|||||||
- VALKEY_HOST=searxng-valkey
|
- VALKEY_HOST=searxng-valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Add to `settings.yml` plugins section:
|
6. Add to `settings.yml` plugins section:
|
||||||
```yaml
|
```yaml
|
||||||
plugins:
|
plugins:
|
||||||
searx.plugins.ollama_answers.SXNGPlugin:
|
searx.plugins.ollama_answers.SXNGPlugin:
|
||||||
active: true
|
active: true
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Restart SearXNG:
|
7. Restart SearXNG:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --force-recreate core
|
docker compose up -d --force-recreate core
|
||||||
```
|
```
|
||||||
@@ -96,6 +102,48 @@ Configure via environment variables.
|
|||||||
6. Client-side script calls a signed endpoint (`/ai-stream`)
|
6. Client-side script calls a signed endpoint (`/ai-stream`)
|
||||||
7. Ollama streams a response token-by-token in the UI
|
7. Ollama streams a response token-by-token in the UI
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- [ ] 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -105,35 +153,35 @@ Configure via environment variables.
|
|||||||
└────────────────┬────────────────────────────────────┘
|
└────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌────────────────▼────────────────────────────────────┐
|
┌────────────────▼────────────────────────────────────┐
|
||||||
│ SearXNG + Plugin │
|
│ SearXNG + Plugin │
|
||||||
│ │
|
│ │
|
||||||
│ post_search() │
|
│ post_search() │
|
||||||
│ → _enrich_results() ← ThreadPoolExecutor │
|
│ → _enrich_results() ← ThreadPoolExecutor │
|
||||||
│ → _fetch_page_text() × 5 parallel │
|
│ → _fetch_page_text() × 5 parallel │
|
||||||
│ → _chunk_text() + _tfidf_score() │
|
│ → _chunk_text() + _tfidf_score() │
|
||||||
│ → rerank by score │
|
│ → rerank by score │
|
||||||
│ → _assemble_context() │
|
│ → _assemble_context() │
|
||||||
│ → inject AI Overview HTML + JS │
|
│ → inject AI Overview HTML + JS │
|
||||||
│ │
|
│ │
|
||||||
│ /ai-stream │
|
│ /ai-stream │
|
||||||
│ → validate token │
|
│ → validate token │
|
||||||
│ → _detect_intent() → select system prompt │
|
│ → _detect_intent() → select system prompt │
|
||||||
│ → _load_conversation() from Valkey │
|
│ → _load_conversation() from Valkey │
|
||||||
│ → launch stream_to_valkey() thread │
|
│ → launch stream_to_valkey() thread │
|
||||||
│ → return {job_id} immediately │
|
│ → return {job_id} immediately │
|
||||||
│ │
|
│ │
|
||||||
│ stream_to_valkey() [background thread] │
|
│ stream_to_valkey() [background thread] │
|
||||||
│ → Ollama stream=True │
|
│ → Ollama stream=True │
|
||||||
│ → RPUSH tokens to Valkey │
|
│ → RPUSH tokens to Valkey │
|
||||||
│ → RPUSH __DONE__ when complete │
|
│ → RPUSH __DONE__ when complete │
|
||||||
│ │
|
│ │
|
||||||
│ /ai-status/{job_id} │
|
│ /ai-status/{job_id} │
|
||||||
│ → LRANGE chunks from offset │
|
│ → LRANGE chunks from offset │
|
||||||
│ → return {chunks, done} │
|
│ → return {chunks, done} │
|
||||||
└────────────────┬────────────────────────────────────┘
|
└────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌────────────────▼────────────────────────────────────┐
|
┌────────────────▼────────────────────────────────────┐
|
||||||
│ Valkey │
|
│ Valkey │
|
||||||
│ ai:job:{id}:chunks (list, TTL 120s) │
|
│ ai:job:{id}:chunks (list, TTL 120s) │
|
||||||
│ ai:job:{id}:status (string, TTL 120s) │
|
│ ai:job:{id}:status (string, TTL 120s) │
|
||||||
│ ai:conv:{session} (JSON, TTL 1800s) │
|
│ ai:conv:{session} (JSON, TTL 1800s) │
|
||||||
|
|||||||
@@ -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
|
||||||
+1482
File diff suppressed because it is too large
Load Diff
+192
-108
@@ -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 collections import Counter
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from searx import network
|
|
||||||
try:
|
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 request, abort, jsonify
|
||||||
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
|
||||||
@@ -24,7 +23,6 @@ except ImportError:
|
|||||||
logger.warning("AI Answers: valkey package not found. Streaming via Valkey unavailable.")
|
logger.warning("AI Answers: valkey package not found. Streaming via Valkey unavailable.")
|
||||||
|
|
||||||
TOKEN_EXPIRY_SEC = 3600
|
TOKEN_EXPIRY_SEC = 3600
|
||||||
STREAM_CHUNK_SIZE = 512
|
|
||||||
STREAM_TIMEOUT_SEC = 60
|
STREAM_TIMEOUT_SEC = 60
|
||||||
CONV_TTL = 1800
|
CONV_TTL = 1800
|
||||||
|
|
||||||
@@ -276,17 +274,17 @@ INTERACTIVE_CSS = '''
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: 1px solid var(--color-result-border, rgba(0,0,0,0.1));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--color-sidebar-bg, #424247);
|
background: var(--color-base-background-hover, rgba(0,0,0,0.06));
|
||||||
color: var(--color-search-url, #bbb);
|
color: var(--color-base-font, inherit);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.sxng-btn:hover {
|
.sxng-btn:hover {
|
||||||
background: var(--color-search-url, #303033);
|
background: var(--color-result-border, rgba(0,0,0,0.15));
|
||||||
color: var(--color-sidebar-bg, #bbb);
|
color: var(--color-base-font, inherit);
|
||||||
}
|
}
|
||||||
.sxng-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
.sxng-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
||||||
.sxng-input-wrapper {
|
.sxng-input-wrapper {
|
||||||
@@ -300,9 +298,9 @@ INTERACTIVE_CSS = '''
|
|||||||
.sxng-input {
|
.sxng-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: -webkit-fill-available;
|
height: -webkit-fill-available;
|
||||||
background: var(--color-sidebar-bg, #424247);
|
background: var(--color-base-background-hover, rgba(0,0,0,0.06));
|
||||||
border: none;
|
border: 1px solid var(--color-result-border, rgba(0,0,0,0.15));
|
||||||
color: var(--color-base-font, #cdd6f4);
|
color: var(--color-base-font, inherit);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
font-size: 0.78em;
|
font-size: 0.78em;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -311,7 +309,7 @@ INTERACTIVE_CSS = '''
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.sxng-input:focus { outline: none; }
|
.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 {
|
.sxng-input-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -335,23 +333,24 @@ INTERACTIVE_CSS = '''
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
animation: sxng-fade-in-up 0.3s ease-out forwards;
|
animation: sxng-fade-in-up 0.3s ease-out forwards;
|
||||||
}
|
}
|
||||||
.sxng-input-wrapper:focus-within {
|
.sxng-input-wrapper:focus-within {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--color-result-link, #5e81ac);
|
color: var(--color-result-link, #5e81ac);
|
||||||
background: var(--color-base-background-hover, rgba(0,0,0,0.05)) !important;
|
background: var(--color-base-background-hover, rgba(0,0,0,0.05)) !important;
|
||||||
}
|
}
|
||||||
.sxng-model-select {
|
.sxng-model-select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-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: 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;
|
text-overflow: ellipsis;
|
||||||
border-width: 0 2rem 0 0;
|
border: 0px solid var(--color-result-border, rgba(0,0,0,0.1));
|
||||||
border-color: transparent;
|
border-right-width: 2rem;
|
||||||
|
border-right-color: transparent;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
outline: none;
|
outline: none;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
color: var(--color-search-url, #bbb);
|
color: var(--color-base-font, inherit);
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
padding: 1px 10px 1px 10px !important;
|
padding: 1px 10px 1px 10px !important;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -361,8 +360,7 @@ INTERACTIVE_CSS = '''
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.sxng-model-select:hover {
|
.sxng-model-select:hover {
|
||||||
background-color: #303033;
|
background-color: var(--color-result-border, rgba(0,0,0,0.15));
|
||||||
color: var(--color-search-url, #bbb);
|
|
||||||
}
|
}
|
||||||
.sxng-reasoning {
|
.sxng-reasoning {
|
||||||
margin: 0.5rem 0; padding: 0.5rem;
|
margin: 0.5rem 0; padding: 0.5rem;
|
||||||
@@ -385,7 +383,8 @@ INTERACTIVE_CSS = '''
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: var(--color-result-link, #5e81ac);
|
color: var(--color-result-link, #5e81ac);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.75;
|
opacity: 1;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.sxng-citation-item a:hover {
|
.sxng-citation-item a:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -395,18 +394,18 @@ INTERACTIVE_CSS = '''
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-left: 2px solid var(--color-result-link, #5e81ac);
|
border-left: 2px solid var(--color-result-link, #5e81ac);
|
||||||
opacity: 0.6;
|
opacity: 0.85;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
.sxng-prior-history summary {
|
.sxng-prior-history summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-result-link, #5e81ac);
|
color: var(--color-result-link, #5e81ac);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.sxng-prior-answer {
|
.sxng-prior-answer {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
color: var(--color-base-font, #cdd6f4);
|
color: var(--color-base-font, inherit);
|
||||||
}
|
}
|
||||||
.sxng-md-content {
|
.sxng-md-content {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -507,7 +506,7 @@ CITATION_HELPER_JS = r'''
|
|||||||
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
|
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
|
||||||
let lastIdx = 0;
|
let lastIdx = 0;
|
||||||
const matches = [...text.matchAll(re)];
|
const matches = [...text.matchAll(re)];
|
||||||
|
|
||||||
matches.forEach(match => {
|
matches.forEach(match => {
|
||||||
if (match.index > lastIdx) {
|
if (match.index > lastIdx) {
|
||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
@@ -542,7 +541,7 @@ CITATION_HELPER_JS = r'''
|
|||||||
});
|
});
|
||||||
lastIdx = match.index + match[0].length;
|
lastIdx = match.index + match[0].length;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (lastIdx < text.length) {
|
if (lastIdx < text.length) {
|
||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
s.className = 'sxng-chunk';
|
s.className = 'sxng-chunk';
|
||||||
@@ -600,23 +599,6 @@ INTERACTIVE_JS = r'''
|
|||||||
_ms.appendChild(_o);
|
_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.
|
// conversation saved as base64 URL fragment.
|
||||||
const updateState = () => {
|
const updateState = () => {
|
||||||
try {
|
try {
|
||||||
@@ -636,13 +618,13 @@ INTERACTIVE_JS = r'''
|
|||||||
}
|
}
|
||||||
return btoa(bin);
|
return btoa(bin);
|
||||||
};
|
};
|
||||||
|
|
||||||
let b64 = encodeB64(state);
|
let b64 = encodeB64(state);
|
||||||
while (b64.length > 2000 && state.t.length > 2) {
|
while (b64.length > 2000 && state.t.length > 2) {
|
||||||
state.t.splice(1, 2); // Delete in Q&A pairs
|
state.t.splice(1, 2); // Delete in Q&A pairs
|
||||||
b64 = encodeB64(state);
|
b64 = encodeB64(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
history.replaceState(null, null, '#ai=' + b64);
|
history.replaceState(null, null, '#ai=' + b64);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
};
|
};
|
||||||
@@ -658,17 +640,17 @@ INTERACTIVE_JS = r'''
|
|||||||
if (state.u && Array.isArray(state.u)) {
|
if (state.u && Array.isArray(state.u)) {
|
||||||
urls = state.u;
|
urls = state.u;
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.turns = state.t.map(t => ({
|
conversation.turns = state.t.map(t => ({
|
||||||
role: t.r === 'u' ? 'user' : 'assistant',
|
role: t.r === 'u' ? 'user' : 'assistant',
|
||||||
content: t.c.trim(),
|
content: t.c.trim(),
|
||||||
ts: 0
|
ts: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const injectCitations = (text) => {
|
const injectCitations = (text) => {
|
||||||
return renderCitations(text, urls);
|
return renderCitations(text, urls);
|
||||||
};
|
};
|
||||||
|
|
||||||
data.innerHTML = '';
|
data.innerHTML = '';
|
||||||
conversation.turns.forEach((turn, i) => {
|
conversation.turns.forEach((turn, i) => {
|
||||||
if (turn.role === 'user') {
|
if (turn.role === 'user') {
|
||||||
@@ -686,7 +668,6 @@ INTERACTIVE_JS = r'''
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
if(wrapper) wrapper.style.display = '';
|
|
||||||
if(footer && is_interactive) footer.style.display = 'flex';
|
if(footer && is_interactive) footer.style.display = 'flex';
|
||||||
restored = true;
|
restored = true;
|
||||||
}
|
}
|
||||||
@@ -705,7 +686,18 @@ INTERACTIVE_JS = r'''
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('btn-regen').onclick = async () => {
|
document.getElementById('btn-regen').onclick = async () => {
|
||||||
data.innerHTML = '<span class="sxng-cursor"></span>';
|
// Remove only the last assistant response and its citation footer
|
||||||
|
const lastMd = [...data.querySelectorAll('.sxng-md-content')].pop();
|
||||||
|
if (lastMd) {
|
||||||
|
const nextSib = lastMd.nextElementSibling;
|
||||||
|
if (nextSib && nextSib.classList.contains('sxng-citation-footer')) nextSib.remove();
|
||||||
|
lastMd.remove();
|
||||||
|
}
|
||||||
|
const existingCursor = data.querySelector('.sxng-cursor');
|
||||||
|
if (existingCursor) existingCursor.remove();
|
||||||
|
const regenCursor = document.createElement('span');
|
||||||
|
regenCursor.className = 'sxng-cursor';
|
||||||
|
data.appendChild(regenCursor);
|
||||||
footer.style.display = 'none';
|
footer.style.display = 'none';
|
||||||
|
|
||||||
if (conversation.turns.length > 0 && conversation.turns[conversation.turns.length - 1].role === 'assistant') {
|
if (conversation.turns.length > 0 && conversation.turns[conversation.turns.length - 1].role === 'assistant') {
|
||||||
@@ -745,10 +737,10 @@ INTERACTIVE_JS = r'''
|
|||||||
const handleAction = async (e) => {
|
const handleAction = async (e) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
const val = input.value.trim();
|
const val = input.value.trim();
|
||||||
|
|
||||||
conversation.turns.push({role: 'user', content: val, ts: Date.now()});
|
conversation.turns.push({role: 'user', content: val, ts: Date.now()});
|
||||||
updateState();
|
updateState();
|
||||||
|
|
||||||
const currentText = conversation.turns.slice(0, -1).slice(-6)
|
const currentText = conversation.turns.slice(0, -1).slice(-6)
|
||||||
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
|
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
|
||||||
.join('\\n\\n');
|
.join('\\n\\n');
|
||||||
@@ -771,7 +763,7 @@ INTERACTIVE_JS = r'''
|
|||||||
const newCursor = document.createElement('span');
|
const newCursor = document.createElement('span');
|
||||||
newCursor.className = 'sxng-cursor';
|
newCursor.className = 'sxng-cursor';
|
||||||
data.appendChild(newCursor);
|
data.appendChild(newCursor);
|
||||||
|
|
||||||
const synthesized = synthesizeQuery(q_init, val);
|
const synthesized = synthesizeQuery(q_init, val);
|
||||||
let auxContext = null;
|
let auxContext = null;
|
||||||
try {
|
try {
|
||||||
@@ -788,7 +780,7 @@ INTERACTIVE_JS = r'''
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
|
|
||||||
await startStream(val, currentText, auxContext);
|
await startStream(val, currentText, auxContext);
|
||||||
updateState();
|
updateState();
|
||||||
} else {
|
} else {
|
||||||
@@ -860,16 +852,92 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
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))),
|
||||||
originalSources: [...urls],
|
|
||||||
turns: [{role: 'user', content: q_init, ts: Date.now()}]
|
turns: [{role: 'user', content: q_init, ts: Date.now()}]
|
||||||
};
|
};
|
||||||
const box = document.getElementById('sxng-stream-box');
|
const box = document.getElementById('sxng-stream-box');
|
||||||
const data = document.getElementById('sxng-stream-data');
|
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 restored = false;
|
||||||
let isStreaming = false;
|
let isStreaming = false;
|
||||||
|
|
||||||
__CITATION_HELPER_JS__
|
__CITATION_HELPER_JS__
|
||||||
|
|
||||||
(function applyIntentBadge() {
|
(function applyIntentBadge() {
|
||||||
@@ -932,11 +1000,10 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
console.warn('[AI Answers] Stream already in progress, ignoring duplicate call');
|
console.warn('[AI Answers] Stream already in progress, ignoring duplicate call');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isStreaming = true;
|
isStreaming = true;
|
||||||
try {
|
try {
|
||||||
const ctx = auxContext || conversation.originalContext;
|
const ctx = auxContext || conversation.originalContext;
|
||||||
if (wrapper) wrapper.style.display = '';
|
|
||||||
box.style.display = 'block';
|
box.style.display = 'block';
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -991,6 +1058,11 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
data.appendChild(cursor);
|
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 buffer = '';
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
const flushBuffer = (force = false) => {
|
const flushBuffer = (force = false) => {
|
||||||
@@ -998,8 +1070,7 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
const fragment = renderCitations(buffer, urls);
|
const fragment = renderCitations(buffer, urls);
|
||||||
if (cursor) cursor.before(fragment);
|
streamContainer.appendChild(fragment);
|
||||||
else data.appendChild(fragment);
|
|
||||||
buffer = '';
|
buffer = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1014,12 +1085,12 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
s.className = 'sxng-chunk';
|
s.className = 'sxng-chunk';
|
||||||
s.textContent = preText;
|
s.textContent = preText;
|
||||||
cursor.before(s);
|
streamContainer.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
const citationText = match[0];
|
const citationText = match[0];
|
||||||
const fragment = renderCitations(citationText, urls);
|
const fragment = renderCitations(citationText, urls);
|
||||||
cursor.before(fragment);
|
streamContainer.appendChild(fragment);
|
||||||
|
|
||||||
buffer = buffer.substring(match.index + match[0].length);
|
buffer = buffer.substring(match.index + match[0].length);
|
||||||
}
|
}
|
||||||
@@ -1030,7 +1101,7 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
s.className = 'sxng-chunk';
|
s.className = 'sxng-chunk';
|
||||||
s.textContent = buffer;
|
s.textContent = buffer;
|
||||||
cursor.before(s);
|
streamContainer.appendChild(s);
|
||||||
buffer = '';
|
buffer = '';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1039,7 +1110,7 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
s.className = 'sxng-chunk';
|
s.className = 'sxng-chunk';
|
||||||
s.textContent = safeChunk;
|
s.textContent = safeChunk;
|
||||||
cursor.before(s);
|
streamContainer.appendChild(s);
|
||||||
}
|
}
|
||||||
buffer = buffer.substring(openIdx);
|
buffer = buffer.substring(openIdx);
|
||||||
|
|
||||||
@@ -1047,7 +1118,7 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
const s = document.createElement('span');
|
const s = document.createElement('span');
|
||||||
s.className = 'sxng-chunk';
|
s.className = 'sxng-chunk';
|
||||||
s.textContent = buffer[0];
|
s.textContent = buffer[0];
|
||||||
cursor.before(s);
|
streamContainer.appendChild(s);
|
||||||
buffer = buffer.substring(1);
|
buffer = buffer.substring(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1109,15 +1180,9 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamContainer.remove();
|
||||||
if (cursor) cursor.remove();
|
if (cursor) cursor.remove();
|
||||||
|
|
||||||
// Replace streamed text nodes with markdown-rendered content
|
|
||||||
Array.from(data.childNodes).forEach(node => {
|
|
||||||
if (node.nodeType !== 1 || !node.classList.contains('sxng-prior-history')) {
|
|
||||||
node.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rendered = parseMarkdown(fullText.trim());
|
const rendered = parseMarkdown(fullText.trim());
|
||||||
const mdDiv = document.createElement('div');
|
const mdDiv = document.createElement('div');
|
||||||
mdDiv.className = 'sxng-md-content';
|
mdDiv.className = 'sxng-md-content';
|
||||||
@@ -1144,13 +1209,13 @@ FRONTEND_JS_TEMPLATE = r"""
|
|||||||
console.error('[AI Answers] Fatal stream exception:', e);
|
console.error('[AI Answers] Fatal stream exception:', e);
|
||||||
const errSpan = document.createElement('span');
|
const errSpan = document.createElement('span');
|
||||||
errSpan.style.cssText = 'color: #bf616a; font-weight: bold; display: block; margin-top: 0.5rem;';
|
errSpan.style.cssText = 'color: #bf616a; font-weight: bold; display: block; margin-top: 0.5rem;';
|
||||||
|
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
errSpan.textContent = "⚠️ Connection to AI provider timed out.";
|
errSpan.textContent = "⚠️ Connection to AI provider timed out.";
|
||||||
} else {
|
} else {
|
||||||
errSpan.textContent = "⚠️ AI Widget encountered a fatal error. Check browser console.";
|
errSpan.textContent = "⚠️ AI Widget encountered a fatal error. Check browser console.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const cursor = data.querySelector('.sxng-cursor');
|
const cursor = data.querySelector('.sxng-cursor');
|
||||||
if (cursor) cursor.remove();
|
if (cursor) cursor.remove();
|
||||||
@@ -1281,8 +1346,6 @@ INTENT_CONFIGS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
import typing
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from searx.search import SearchWithPlugins
|
from searx.search import SearchWithPlugins
|
||||||
from searx.extended_types import SXNG_Request
|
from searx.extended_types import SXNG_Request
|
||||||
@@ -1372,7 +1435,7 @@ class SXNGPlugin(Plugin):
|
|||||||
'content': str(ib.get('content') or '')[:2000],
|
'content': str(ib.get('content') or '')[:2000],
|
||||||
'attributes': ib.get('attributes', [])
|
'attributes': ib.get('attributes', [])
|
||||||
})
|
})
|
||||||
|
|
||||||
answers = []
|
answers = []
|
||||||
for a in list(raw_answers)[:2]:
|
for a in list(raw_answers)[:2]:
|
||||||
ans_text = ""
|
ans_text = ""
|
||||||
@@ -1382,7 +1445,7 @@ class SXNGPlugin(Plugin):
|
|||||||
ans_text = str(a['answer'])
|
ans_text = str(a['answer'])
|
||||||
if ans_text and 'id="sxng-stream-box"' not in ans_text and not ans_text.strip().startswith('<'):
|
if ans_text and 'id="sxng-stream-box"' not in ans_text and not ans_text.strip().startswith('<'):
|
||||||
answers.append(ans_text)
|
answers.append(ans_text)
|
||||||
|
|
||||||
return results, infoboxes, answers
|
return results, infoboxes, answers
|
||||||
|
|
||||||
def init(self, app):
|
def init(self, app):
|
||||||
@@ -1390,10 +1453,10 @@ class SXNGPlugin(Plugin):
|
|||||||
def ai_auxiliary_search():
|
def ai_auxiliary_search():
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
token = data.get('tk', '')
|
token = data.get('tk', '')
|
||||||
|
|
||||||
# Token access control
|
# Token access control
|
||||||
try:
|
try:
|
||||||
ts, sig = token.rsplit('.', 1)
|
ts, sig = token.rsplit('.', 1)
|
||||||
@@ -1413,13 +1476,13 @@ class SXNGPlugin(Plugin):
|
|||||||
offset = data.get('offset', 0)
|
offset = data.get('offset', 0)
|
||||||
if not query:
|
if not query:
|
||||||
return jsonify({'results': []})
|
return jsonify({'results': []})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from searx.search import SearchWithPlugins
|
from searx.search import SearchWithPlugins
|
||||||
from searx.search.models import SearchQuery
|
from searx.search.models import SearchQuery
|
||||||
from searx.query import RawTextQuery
|
from searx.query import RawTextQuery
|
||||||
from searx.webadapter import get_engineref_from_category_list
|
from searx.webadapter import get_engineref_from_category_list
|
||||||
|
|
||||||
preferences = getattr(request, 'preferences', None)
|
preferences = getattr(request, 'preferences', None)
|
||||||
disabled_engines = preferences.engines.get_disabled() if preferences else []
|
disabled_engines = preferences.engines.get_disabled() if preferences else []
|
||||||
rtq = RawTextQuery(query, disabled_engines)
|
rtq = RawTextQuery(query, disabled_engines)
|
||||||
@@ -1427,7 +1490,7 @@ class SXNGPlugin(Plugin):
|
|||||||
category_list = [c.strip() for c in categories.split(',') if c.strip()]
|
category_list = [c.strip() for c in categories.split(',') if c.strip()]
|
||||||
else:
|
else:
|
||||||
category_list = categories or ['general']
|
category_list = categories or ['general']
|
||||||
|
|
||||||
enginerefs = get_engineref_from_category_list(category_list, disabled_engines)
|
enginerefs = get_engineref_from_category_list(category_list, disabled_engines)
|
||||||
sq = SearchQuery(
|
sq = SearchQuery(
|
||||||
query=rtq.getQuery(),
|
query=rtq.getQuery(),
|
||||||
@@ -1437,19 +1500,19 @@ class SXNGPlugin(Plugin):
|
|||||||
)
|
)
|
||||||
search_obj = SearchWithPlugins(sq, request, user_plugins=[])
|
search_obj = SearchWithPlugins(sq, request, user_plugins=[])
|
||||||
result_container = search_obj.search()
|
result_container = search_obj.search()
|
||||||
|
|
||||||
raw_results = result_container.get_ordered_results()
|
raw_results = result_container.get_ordered_results()
|
||||||
raw_infoboxes = getattr(result_container, 'infoboxes', [])
|
raw_infoboxes = getattr(result_container, 'infoboxes', [])
|
||||||
raw_answers = getattr(result_container, 'answers', [])
|
raw_answers = getattr(result_container, 'answers', [])
|
||||||
|
|
||||||
results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_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)
|
context_str, new_urls = self._assemble_context(results, infoboxes, answers, offset)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'context': context_str,
|
'context': context_str,
|
||||||
'new_urls': new_urls,
|
'new_urls': new_urls,
|
||||||
'results': results,
|
'results': results,
|
||||||
'infoboxes': infoboxes,
|
'infoboxes': infoboxes,
|
||||||
'answers': answers,
|
'answers': answers,
|
||||||
'query': query
|
'query': query
|
||||||
@@ -1662,6 +1725,16 @@ class SXNGPlugin(Plugin):
|
|||||||
|
|
||||||
job_id = hashlib.sha256(f"{time.time()}{q}".encode()).hexdigest()[:16]
|
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 = {
|
payload_dict = {
|
||||||
"model": effective_model,
|
"model": effective_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -1869,12 +1942,12 @@ class SXNGPlugin(Plugin):
|
|||||||
"""Builds context string from normalized search data. Returns (context_str, urls)."""
|
"""Builds context string from normalized search data. Returns (context_str, urls)."""
|
||||||
context_parts = []
|
context_parts = []
|
||||||
result_urls = []
|
result_urls = []
|
||||||
|
|
||||||
knowledge_graph_lines = []
|
knowledge_graph_lines = []
|
||||||
for ib in infoboxes:
|
for ib in infoboxes:
|
||||||
ib_name = ib.get('name', '') or ib.get('infobox', '') or ib.get('title', '')
|
ib_name = ib.get('name', '') or ib.get('infobox', '') or ib.get('title', '')
|
||||||
ib_content = str(ib.get('content', '')).replace('\n', ' ').strip()
|
ib_content = str(ib.get('content', '')).replace('\n', ' ').strip()
|
||||||
|
|
||||||
if ib_name:
|
if ib_name:
|
||||||
parts = [f"INFOBOX [{ib_name}]:"]
|
parts = [f"INFOBOX [{ib_name}]:"]
|
||||||
if ib_content:
|
if ib_content:
|
||||||
@@ -1884,16 +1957,16 @@ class SXNGPlugin(Plugin):
|
|||||||
attr_value = attr.get('value', '')
|
attr_value = attr.get('value', '')
|
||||||
if attr_label and attr_value:
|
if attr_label and attr_value:
|
||||||
parts.append(f" {attr_label}: {attr_value}")
|
parts.append(f" {attr_label}: {attr_value}")
|
||||||
|
|
||||||
knowledge_graph_lines.append(" ".join(parts) if len(parts) == 2 else "\n".join(parts))
|
knowledge_graph_lines.append(" ".join(parts) if len(parts) == 2 else "\n".join(parts))
|
||||||
|
|
||||||
for ans_text in answers:
|
for ans_text in answers:
|
||||||
if ans_text and not str(ans_text).startswith('<'):
|
if ans_text and not str(ans_text).startswith('<'):
|
||||||
knowledge_graph_lines.append(f"ANSWER: {str(ans_text)[:300]}")
|
knowledge_graph_lines.append(f"ANSWER: {str(ans_text)[:300]}")
|
||||||
|
|
||||||
if knowledge_graph_lines:
|
if knowledge_graph_lines:
|
||||||
context_parts.append("KNOWLEDGE GRAPH:\n" + "\n".join(knowledge_graph_lines))
|
context_parts.append("KNOWLEDGE GRAPH:\n" + "\n".join(knowledge_graph_lines))
|
||||||
|
|
||||||
deep_lines = []
|
deep_lines = []
|
||||||
for i, r in enumerate(clean_results[:self.context_deep_count]):
|
for i, r in enumerate(clean_results[:self.context_deep_count]):
|
||||||
url = r.get('url', '')
|
url = r.get('url', '')
|
||||||
@@ -1909,10 +1982,10 @@ class SXNGPlugin(Plugin):
|
|||||||
logger.debug(f"{PLUGIN_NAME}: falling back to snippet for [{idx}] {domain}")
|
logger.debug(f"{PLUGIN_NAME}: falling back to snippet for [{idx}] {domain}")
|
||||||
content = str(r.get('content', '')).replace('\n', ' ').strip()[:800]
|
content = str(r.get('content', '')).replace('\n', ' ').strip()[:800]
|
||||||
deep_lines.append(f"[{idx}] {domain}{date_str}: {title}: {content}")
|
deep_lines.append(f"[{idx}] {domain}{date_str}: {title}: {content}")
|
||||||
|
|
||||||
if deep_lines:
|
if deep_lines:
|
||||||
context_parts.append("DEEP SOURCES:\n" + "\n".join(deep_lines))
|
context_parts.append("DEEP SOURCES:\n" + "\n".join(deep_lines))
|
||||||
|
|
||||||
if self.context_shallow_count > 0:
|
if self.context_shallow_count > 0:
|
||||||
shallow_lines = []
|
shallow_lines = []
|
||||||
start_idx = self.context_deep_count
|
start_idx = self.context_deep_count
|
||||||
@@ -1924,10 +1997,10 @@ class SXNGPlugin(Plugin):
|
|||||||
title = r.get('title', '').replace('\n', ' ').strip()[:60]
|
title = r.get('title', '').replace('\n', ' ').strip()[:60]
|
||||||
idx = i + 1 + start_idx + offset
|
idx = i + 1 + start_idx + offset
|
||||||
shallow_lines.append(f"[{idx}] {domain}: {title}")
|
shallow_lines.append(f"[{idx}] {domain}: {title}")
|
||||||
|
|
||||||
if shallow_lines:
|
if shallow_lines:
|
||||||
context_parts.append("SHALLOW SOURCES (headlines):\n" + "\n".join(shallow_lines))
|
context_parts.append("SHALLOW SOURCES (headlines):\n" + "\n".join(shallow_lines))
|
||||||
|
|
||||||
return "\n\n".join(context_parts), result_urls
|
return "\n\n".join(context_parts), result_urls
|
||||||
|
|
||||||
def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
|
def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
|
||||||
@@ -1951,7 +2024,7 @@ class SXNGPlugin(Plugin):
|
|||||||
raw_results = search.result_container.get_ordered_results()
|
raw_results = search.result_container.get_ordered_results()
|
||||||
raw_infoboxes = getattr(search.result_container, 'infoboxes', [])
|
raw_infoboxes = getattr(search.result_container, 'infoboxes', [])
|
||||||
raw_answers = getattr(search.result_container, 'answers', [])
|
raw_answers = getattr(search.result_container, 'answers', [])
|
||||||
|
|
||||||
q_clean = search.search_query.query.strip()
|
q_clean = search.search_query.query.strip()
|
||||||
clean_results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers)
|
clean_results, infoboxes, answers = self._parse_aux_results(raw_results, raw_infoboxes, raw_answers)
|
||||||
clean_results = self._enrich_results(clean_results, q_clean)
|
clean_results = self._enrich_results(clean_results, q_clean)
|
||||||
@@ -1974,12 +2047,23 @@ class SXNGPlugin(Plugin):
|
|||||||
|
|
||||||
detected_intent = _detect_intent(q_clean)
|
detected_intent = _detect_intent(q_clean)
|
||||||
js_intent = safe_json(detected_intent)
|
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')
|
b64_context = base64.b64encode(context_str.encode('utf-8')).decode('utf-8')
|
||||||
total_context_count = self.context_deep_count + self.context_shallow_count
|
total_context_count = self.context_deep_count + self.context_shallow_count
|
||||||
|
|
||||||
raw_urls = [r.get('url', '') for r in clean_results[:total_context_count]]
|
raw_urls = [r.get('url', '') for r in clean_results[:total_context_count]]
|
||||||
|
|
||||||
js_q = safe_json(q_clean)
|
js_q = safe_json(q_clean)
|
||||||
js_lang = safe_json(lang)
|
js_lang = safe_json(lang)
|
||||||
js_urls = safe_json(raw_urls)
|
js_urls = safe_json(raw_urls)
|
||||||
@@ -2021,7 +2105,7 @@ class SXNGPlugin(Plugin):
|
|||||||
.replace("__JS_Q__", js_q)
|
.replace("__JS_Q__", js_q)
|
||||||
|
|
||||||
html_payload = f'''
|
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>
|
<style>
|
||||||
@keyframes sxng-fade-pulse {{
|
@keyframes sxng-fade-pulse {{
|
||||||
0%, 100% {{ opacity: 0.1; }}
|
0%, 100% {{ opacity: 0.1; }}
|
||||||
@@ -2071,11 +2155,11 @@ class SXNGPlugin(Plugin):
|
|||||||
</style>
|
</style>
|
||||||
<div class="sxng-ai-header">
|
<div class="sxng-ai-header">
|
||||||
<span class="sxng-ai-label">
|
<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>
|
</span>
|
||||||
<select id="sxng-model-select" class="sxng-model-select" title="Select model"></select>
|
<select id="sxng-model-select" class="sxng-model-select" title="Select model"></select>
|
||||||
</div>
|
</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}
|
{interactive_html}
|
||||||
<script>
|
<script>
|
||||||
{js_code}
|
{js_code}
|
||||||
@@ -2085,4 +2169,4 @@ class SXNGPlugin(Plugin):
|
|||||||
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload)))
|
search.result_container.answers.add(results.types.Answer(answer=Markup(html_payload)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{PLUGIN_NAME}: {e}")
|
logger.error(f"{PLUGIN_NAME}: {e}")
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
flask
|
flask
|
||||||
flask-babel
|
flask-babel
|
||||||
certifi
|
certifi
|
||||||
|
python-dotenv
|
||||||
|
valkey
|
||||||
-346
@@ -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>⚙ 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)
|
|
||||||
Reference in New Issue
Block a user