Compare commits

..

51 Commits

Author SHA1 Message Date
TySS-Dev 888764c45a Fixing typos 2026-05-25 20:36:56 -04:00
TySS-Dev 3030c5e10b Merge pull request 'Fixing Intent in Dev.py' (#4) from testing into main
Reviewed-on: #4
2026-05-19 15:21:45 -04:00
tyler ad8f1397bc Fixed intent in the dev server 2026-05-19 15:20:06 -04:00
Tyler bda9e5a462 Fixing Intent in Dev.py 2026-05-19 14:30:42 -04:00
TySS-Dev 31c8b96f5e Merge pull request 'Fixed mayber' (#3) from testing into main
Reviewed-on: #3
2026-05-19 06:01:40 -04:00
TySS-Dev f471140bd6 Upload files to "dev" 2026-05-19 06:01:19 -04:00
TySS-Dev 68ff90b655 Upload files to "/" 2026-05-19 06:01:01 -04:00
TySS-Dev 177fd5862e Reworked dev.py, and made changes related to the rework 2026-05-19 05:59:32 -04:00
TySS-Dev 6daf947d00 Delete directory 'tests' 2026-05-19 03:09:34 -04:00
tyler af3e74c92a Moved dev.py to dev/ 2026-05-19 03:09:18 -04:00
tyler 7e06e07e4f moved an import line to the top 2026-05-19 03:08:57 -04:00
tyler 8b7c8f7df8 Adding base64 import back 2026-05-19 02:59:01 -04:00
tyler 93d263cdc3 Cleaning up code 2026-05-19 02:56:01 -04:00
tyler b914d13d4e Removing the boarder around model dropdown 2026-05-19 02:48:10 -04:00
tyler eee0fd8709 Revert ollama_answers.py to theme-aware state before border animation 2026-05-19 02:46:56 -04:00
tyler baec4522cf Fixing a animation around the input box 2026-05-19 02:43:27 -04:00
tyler 1702d9cd20 Fixing a animation around the input box 2026-05-19 02:40:42 -04:00
tyler 2a5a501a96 Fixing a animation around the input box 2026-05-19 02:39:00 -04:00
tyler 64aa62f5e0 Fixing a animation around the input box 2026-05-19 02:35:05 -04:00
tyler 378a485ba7 Adding a animation around the input box 2026-05-19 02:32:53 -04:00
tyler f66264b92a Attempting to make elements theme aware 2026-05-19 02:23:26 -04:00
tyler ff3b75d129 Attempting to make elements theme aware 2026-05-19 02:20:49 -04:00
tyler 08d4915d4a Attempting to make elements theme aware 2026-05-19 02:16:12 -04:00
tyler ce42f9a652 Attempting to make elements theme aware 2026-05-19 02:12:18 -04:00
tyler 9e784c8b8b Attempting to make elements theme aware 2026-05-19 02:07:25 -04:00
tyler 8e7752c2de Updated Issues in README 2026-05-19 02:07:01 -04:00
tyler 78941479db Reworking css 2026-05-19 01:49:18 -04:00
tyler 83494bb023 Reworking our injection 2026-05-19 00:05:50 -04:00
tyler e46c752aec Maybe working divider 2026-05-19 00:02:29 -04:00
tyler 541d98f7f1 Maybe working divider 2026-05-18 23:56:23 -04:00
tyler 4c749b825c Fixing conversation history and couldn't figure out how to remove SearXNG info box so just adding a smart divider 2026-05-18 23:53:04 -04:00
tyler 23ecac6afa Fixed mayber 2026-05-18 23:14:22 -04:00
Tyler 4b36a261c4 Attempting to fix conversation history 2026-05-18 15:11:18 -04:00
TySS-Dev eeac7fcd88 Added more known issues 2026-05-17 20:13:19 -04:00
TySS-Dev 1c3824b7a4 Fixed typo 2026-05-17 20:01:43 -04:00
TySS-Dev a7c031d27b Fixed check boxes 2026-05-17 20:00:56 -04:00
TySS-Dev 5e2b2a246f Added known issues and roadmap 2026-05-17 19:59:55 -04:00
TySS-Dev ffad0de8ae Fixed flow diagram 2026-05-17 19:51:19 -04:00
TySS-Dev 3dffeb384b Fixed a typo in README 2026-05-17 19:46:04 -04:00
TySS-Dev 85d1481bd9 Updated README 2026-05-17 19:45:37 -04:00
Tyler 904cf945a2 Updated README 2026-05-17 16:07:00 -04:00
Tyler b3dc603b94 Better markdown support 2026-05-17 16:02:31 -04:00
Tyler 4e2f9d97d7 Adding intent based prompting 2026-05-17 15:53:44 -04:00
Tyler 1f7d54590f Adding conversation memory 2026-05-17 15:44:53 -04:00
Tyler 2ed6a0aae9 Result filtering by relevance and RAG with chucnking logic 2026-05-17 15:27:21 -04:00
Tyler 9d6d4ec160 Fixing content not loading 2026-05-17 15:19:44 -04:00
Tyler e4880a7a51 Adding debug logic 2026-05-17 15:17:44 -04:00
Tyler 332834a126 Adding better AI response streaming logic 2026-05-17 15:11:01 -04:00
Tyler 59c46222b5 Updated dropdown hight 2026-05-17 14:43:44 -04:00
TySS-Dev a69d7f7938 Changed my username so updated link again 2026-05-17 03:28:22 -04:00
TySS-Dev 8402fdb631 Fixing main repo link 2026-05-17 03:25:59 -04:00
12 changed files with 3265 additions and 1936 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ __pycache__/
*$py.class *$py.class
venv/ venv/
.env .env
dev/.env
.idea/ .idea/
.vscode/ .vscode/
.agent/ .agent/
dist/
+220 -100
View File
@@ -1,83 +1,81 @@
<div align="center"> <div align="center">
# ollama-ai-answers-searxng [![Main Repo](https://img.shields.io/badge/Main%20Repo-git.tysstech.com-blue?logo=gitea)](https://git.tysstech.com/TySS-Dev/ollama-ai-answers-searxng)
[![Mirror Repo](https://img.shields.io/badge/Mirror%20Repo-github.com-blue?logo=github)](https://github.com/TySP-Dev/ollama-ai-answers-searxng)
**Local AI search overviews for SearXNG, powered by Ollama.**
![Python](https://img.shields.io/badge/python-3.8%2B-blue?logo=python)
![License](https://img.shields.io/badge/license-MIT-green)
![SearXNG](https://img.shields.io/badge/SearXNG-compatible-orange?logo=searxng)
[![Mirror Repo](https://img.shields.io/badge/GitHub-TySP--Dev%2Follama--ai--answers--searxng-blue?logo=github)](https://github.com/TySP-Dev/ollama-ai-answers-searxng)
</div> </div>
## One-line Install # Ollama AI Answers Plugin for SearXNG
**Based on [ai-answers-searxng](https://github.com/cra88y/ai-answers-searxng) by [cra88y](https://github.com/cra88y)**
```bash A SearXNG plugin that generates local AI overviews powered by Ollama, using search results as RAG context.
bash <(curl -fsSL https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/master/install.sh)
```
## Features ## Features:
- AI Overview box at the top of every search result page - Inline numbered citations
- Powered entirely by your local Ollama instance — no external API calls - Interactive mode - Continue summary, ask follow-ups, copy, or regenerate
- Page content fetching — enriches context beyond SearXNG snippets - Overview of ranked results with prompts based on detected query intent:
- Model selector dropdown — switch models per-search without restarting - `How To` `Technical` `Factual` `Comparison` `Opinion` `Current` `Local` `Geneal`
- Inline citations with clickable source links - Internally called RAG for follow-ups
- Citation footer listing all referenced sources - Native network integration via `searx.network`
- Follow-up questions with conversation history - Stateless conversation presistence/shareability via URL hash
- Copy and Regenerate buttons - Ollama model selector
- Typewriter animation (granian-compatible buffered response) - Feeds fetched results to Ollama without slowing down SearXNG results
- Ollama-only — no OpenAI, Gemini, or other provider bloat - Real-time streaming via Valkey (No waiting for a completed response)
- TF-IDF result ranking before being sent to Ollama
## Requirements - Smart chunking - Pages are split into 512-token segments and highest-scoring chunk per page used for context
- Conversation memory - 30-minute cross-search conversation history via Valkey for follow-up questions
- SearXNG installed via Docker Compose - Markdown support
- Ollama running and accessible from the SearXNG container - Intent emoji badge showing what intent prompt was used
- Python 3.8+ (for `build.py` and `install.sh`)
- Docker + Docker Compose
## Install ## Install
### One-line (recommended) 1. Download the plugin:
```bash ### Main repo (Gitea)
bash <(curl -fsSL https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/master/install.sh) ```bash
``` curl -o ollama_answers.py https://git.tysstech.com/TySS-Dev/ollama-ai-answers-searxng/raw/branch/main/ollama_answers.py
```
The script will clone the repo, build the plugin, detect your SearXNG Docker Compose installation, copy the plugin, update `docker-compose.yml` and `settings.yml`, and optionally restart SearXNG. ### Mirror repo (Github):
```bash
curl -o ollama_answers.py https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/main/ollama_answers.py
```
### Manual 3. Copy to your SearXNG plugins directory:
```bash
cp ollama_answers.py path_to/searxng/plugins/ollama_answers.py
```
```bash 4. Add the volume mount to your `docker-compose.yml` under the searxng service:
git clone https://github.com/TySP-Dev/ollama-ai-answers-searxng ```yaml
cd ollama-ai-answers-searxng volumes:
python3 build.py - ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z
bash install.sh ```
```
Or manually copy the built plugin and update your config: 5. Add environment variables to `docker-compose.yml`:
```yaml
```yaml environment:
# docker-compose.yml — searxng service
environment:
- LLM_URL=http://ollama:11434/v1/chat/completions - LLM_URL=http://ollama:11434/v1/chat/completions
- LLM_MODEL=qwen3.5:9b - LLM_MODEL=qwen3.5:9b
volumes: - VALKEY_HOST=searxng-valkey
- ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z ```
```
```yaml 6. Add to `settings.yml` plugins section:
# settings.yml ```yaml
plugins: plugins:
searx.plugins.ollama_answers.SXNGPlugin: searx.plugins.ollama_answers.SXNGPlugin:
active: true active: true
``` ```
7. Restart SearXNG:
```bash
docker compose up -d --force-recreate core
```
## Configuration ## Configuration
All configuration is done via environment variables on the SearXNG container. Configure via environment variables.
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
@@ -85,65 +83,187 @@ All configuration is done via environment variables on the SearXNG container.
| `LLM_MODEL` | `qwen3.5:9b` | Default model | | `LLM_MODEL` | `qwen3.5:9b` | Default model |
| `LLM_MAX_TOKENS` | `200` | Max response tokens | | `LLM_MAX_TOKENS` | `200` | Max response tokens |
| `LLM_TEMPERATURE` | `0.2` | Response temperature | | `LLM_TEMPERATURE` | `0.2` | Response temperature |
| `LLM_TABS` | `general,science,it,news` | Search tabs to show AI overview on | | `LLM_TABS` | `general,science,it,news` | Tabs to show AI overview on |
| `LLM_QUESTION_MARK_REQUIRED` | `false` | Only trigger on queries ending with `?` | | `LLM_QUESTION_MARK_REQUIRED` | `false` | Only trigger on queries with `?` |
| `LLM_INTERACTIVE` | `true` | Show copy/regenerate/follow-up UI | | `LLM_INTERACTIVE` | `true` | Show copy/regen/follow-up UI |
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Override the system prompt | | `LLM_SYSTEM_PROMPT` | *(built-in)* | Override the system prompt |
| `LLM_CONTEXT_DEEP_COUNT` | `5` | Results fetched for full page content | | `LLM_CONTEXT_DEEP_COUNT` | `5` | Full-content results to fetch |
| `LLM_CONTEXT_SHALLOW_COUNT` | `15` | Results used as headline-only context | | `LLM_CONTEXT_SHALLOW_COUNT` | `15` | Headline-only results |
| `VALKEY_HOST` | `searxng-valkey` | Valkey container hostname |
| `VALKEY_PORT` | `6379` | Valkey port |
## How It Works
1. User performs a search
2. Results return server-side
3. `post_search` plugin hook fires
4. Token-optimized context is extracted from results
5. UI/logic shell injected into the standard answers object
6. Client-side script calls a signed endpoint (`/ai-stream`)
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
```
┌─────────────────────────────────────────────────────┐
│ Browser │
│ POST /ai-stream → GET /ai-status/{id} (poll 150ms) │
└────────────────┬────────────────────────────────────┘
┌────────────────▼────────────────────────────────────┐
│ SearXNG + Plugin │
│ │
│ post_search() │
│ → _enrich_results() ← ThreadPoolExecutor │
│ → _fetch_page_text() × 5 parallel │
│ → _chunk_text() + _tfidf_score() │
│ → rerank by score │
│ → _assemble_context() │
│ → inject AI Overview HTML + JS │
│ │
│ /ai-stream │
│ → validate token │
│ → _detect_intent() → select system prompt │
│ → _load_conversation() from Valkey │
│ → launch stream_to_valkey() thread │
│ → return {job_id} immediately │
│ │
│ stream_to_valkey() [background thread] │
│ → Ollama stream=True │
│ → RPUSH tokens to Valkey │
│ → RPUSH __DONE__ when complete │
│ │
│ /ai-status/{job_id} │
│ → LRANGE chunks from offset │
│ → return {chunks, done} │
└────────────────┬────────────────────────────────────┘
┌────────────────▼────────────────────────────────────┐
│ Valkey │
│ ai:job:{id}:chunks (list, TTL 120s) │
│ ai:job:{id}:status (string, TTL 120s) │
│ ai:conv:{session} (JSON, TTL 1800s) │
└─────────────────────────────────────────────────────┘
```
## Docker Compose Example
```yaml
services:
searxng:
environment:
- LLM_URL=http://ollama:11434/v1/chat/completions
- LLM_MODEL=qwen3.5:9b
- VALKEY_HOST=searxng-valkey
volumes:
- ./ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py
ollama:
image: ollama/ollama
volumes:
- ollama_data:/root/.ollama
volumes:
ollama_data:
```
## Remote Ollama
If your Ollama instance is remote or behind a reverse proxy, set `LLM_URL` to the full endpoint and provide an API key if required. The plugin supports Bearer token auth and follows HTTP redirects.
```yaml
environment:
- LLM_URL=https://ollama.example.com/v1/chat/completions
- LLM_API_KEY=your-bearer-token
```
## Project Structure ## Project Structure
``` ```
ollama-ai-answers-searxng/ ollama-ai-answers-searxng/
├── ollama_answers.py # Source plugin — reads UI from assets/ ├── ollama_answers.py # single plugin file — all logic here
├── build.py # Assembles dist/ollama_answers.py (self-contained) ├── README.md
├── install.sh # Full automated Docker Compose installer ├── requirements.txt # flask, flask-babel (for local dev only)
── assets/ ── tests/
── ui.css # Interactive widget styles ── dev.py # local dev server
│ ├── ui.html # Interactive widget HTML (copy/regen/follow-up bar)
│ └── ui.js # Frontend JS (typewriter, citations, streaming)
├── dist/ # Output of build.py — gitignored
│ └── ollama_answers.py # Self-contained, ready to deploy
├── dev/
│ └── dev.py # Local Flask dev server (no SearXNG required)
└── README.md
``` ```
## Development ## Development — Dev Server
A standalone Flask dev server is included in `tests/dev.py`. It mocks the SearXNG plugin environment so you can test the full UI without a running SearXNG instance.
### Setup
```bash ```bash
# Edit source files pip install flask flask-babel certifi
vim ollama_answers.py
vim assets/ui.css
# Build dist file for deployment
python3 build.py
# Deploy to server
cp dist/ollama_answers.py ~/searxng/plugins/ollama_answers.py
cd ~/searxng && docker compose up -d --force-recreate core
# Run local dev server
PYTHONPATH=. python3 dev/dev.py
``` ```
The dev server mocks the SearXNG plugin environment so you can test the full UI without a running SearXNG instance. Open [http://127.0.0.1:5000/](http://127.0.0.1:5000/) after starting it. ### Run
```bash
python tests/dev.py
```
Then open [http://127.0.0.1:5000/](http://127.0.0.1:5000/) in your browser.
> **Note:** Use `127.0.0.1:5000`, not `localhost:5000` — macOS AirPlay Receiver can occupy the IPv6 loopback on port 5000. > **Note:** Use `127.0.0.1:5000`, not `localhost:5000` — macOS AirPlay Receiver can occupy the IPv6 loopback on port 5000.
## How It Works ### Usage
1. User searches on SearXNG - Type a query in the search bar and hit **Search** to trigger an AI overview.
2. `post_search` hook fires after results are fetched - Expand **Ollama Configuration** at the top to change the endpoint URL or Bearer token for the current session. Click **Apply** to save and re-run the current query.
3. Top result URLs are fetched in parallel for full page content - The model selector in the AI overview widget (loaded from `/ai-models`) shows all models available on the configured Ollama server and persists your choice in the session URL.
4. Context is assembled from page content + snippets + infoboxes
5. A signed token is generated and injected into the page
6. The browser POSTs to `/ai-stream` with the token and context
7. The server calls Ollama with the enriched context
8. The response is returned as JSON and animated with a typewriter effect
9. Citations are rendered inline and collected in a footer
## License ### Environment Variables (dev)
MIT License The dev server reads the same variables as the plugin:
```bash
LLM_URL=http://localhost:11434/v1/chat/completions \
LLM_MODEL=qwen3.5:9b \
python tests/dev.py
```
Or export them before running. Any values set in the config panel at runtime take priority for that session.
-134
View File
@@ -1,134 +0,0 @@
@keyframes sxng-fade-in-up {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.sxng-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
opacity: 0;
animation: sxng-fade-in-up 0.5s ease-out forwards;
}
.sxng-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 4px;
background: var(--color-sidebar-bg, #424247);
color: var(--color-search-url, #bbb);
cursor: pointer;
vertical-align: middle;
line-height: 1.4;
}
.sxng-btn:hover {
background: var(--color-search-url, #303033);
color: var(--color-sidebar-bg, #bbb);
}
.sxng-btn svg { width: 18px; height: 18px; fill: currentColor; }
.sxng-input-wrapper {
flex-grow: 1;
display: flex;
height: 32px;
align-items: center;
margin: 0 0.5rem;
position: relative;
}
.sxng-input {
width: 100%;
height: -webkit-fill-available;
background: var(--color-sidebar-bg, #424247);
border: none;
color: var(--color-base-font, #cdd6f4);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 0.78em;
padding: 3px 8px;
border-radius: 4px;
line-height: 1.4;
vertical-align: middle;
}
.sxng-input:focus { outline: none; }
.sxng-input::placeholder { color: var(--color-base-font, #333); opacity: 0.35; }
.sxng-input-line {
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 1px;
background: var(--color-result-link, #5e81ac);
transition: width 0.3s ease;
}
.sxng-input:focus + .sxng-input-line { width: 100%; }
.sxng-user-msg {
display: block;
width: fit-content;
max-width: 80%;
margin: 0.75rem 0 0.75rem auto;
padding: 0.25rem 0.6rem 0.25rem 0;
border-right: 2px solid var(--color-result-link, #5e81ac);
text-align: right;
font-size: 0.85rem;
line-height: 1.4;
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);
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;
text-overflow: ellipsis;
border-width: 0 2rem 0 0;
border-color: transparent;
border-radius: 5px;
outline: none;
height: 32px;
color: var(--color-search-url, #bbb);
font-size: .9rem;
padding: 1px 10px 1px 10px !important;
margin: 0;
cursor: pointer;
display: none;
max-width: 8rem;
vertical-align: middle;
}
.sxng-model-select:hover {
background-color: #303033;
color: var(--color-search-url, #bbb);
}
.sxng-reasoning {
margin: 0.5rem 0; padding: 0.5rem;
border-left: 2px solid var(--color-result-link, #5e81ac);
background: var(--color-base-background-hover, rgba(0,0,0,0.03));
font-size: 0.85rem; opacity: 0.7; transition: opacity 0.2s;
}
.sxng-reasoning:hover { opacity: 1; }
.sxng-reasoning summary { cursor: pointer; font-weight: bold; color: var(--color-result-link, #5e81ac); }
.sxng-thought-content { margin-top: 0.5rem; white-space: pre-wrap; font-family: monospace; }
.sxng-citation-footer {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-sidebar-bg, #424247);
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
}
.sxng-citation-item a {
font-size: 0.75em;
color: var(--color-result-link, #5e81ac);
text-decoration: none;
opacity: 0.75;
}
.sxng-citation-item a:hover {
opacity: 1;
text-decoration: underline;
}
-13
View File
@@ -1,13 +0,0 @@
<div id="sxng-footer" class="sxng-footer" style="display:none;">
<button class="sxng-btn" id="btn-copy" title="Copy to clipboard">
<svg viewBox="0 0 24 24"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1M19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5M19 21H8V7H19V21Z"/></svg>
</button>
<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>
</button>
<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">
<div class="sxng-input-line"></div>
</button>
</form>
</div>
-566
View File
@@ -1,566 +0,0 @@
// === FRONTEND_JS_TEMPLATE ===
(async () => {
const is_interactive = __IS_INTERACTIVE__;
const q_init = __JS_Q__;
const lang_init = __JS_LANG__;
let urls = __JS_URLS__;
const b64_init = __B64_CONTEXT__;
const tk_init = __TK__;
const script_root = __SCRIPT_ROOT__;
const model_init = __MODEL_INIT__;
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';
let restored = false;
let isStreaming = false;
__CITATION_HELPER_JS__
__INTERACTIVE_JS_INIT__
function synthesizeQuery(original, followup) {
const cleanOrig = original.replace(/^(what|how|why|when|where|who|which|is|are|can|does|do)(\s+(is|are|do|does|can|to|a|an|the))?\s+/i, '');
const origWords = cleanOrig.split(' ').slice(0, 12);
return `${origWords.join(' ')} ${followup}`.trim();
}
__STREAM_FN_SIG__ {
if (isStreaming) {
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();
let timeoutId = setTimeout(() => controller.abort(), 60000);
const finalQ = __STREAM_Q__;
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyObj),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!res.ok) {
const errSpan = document.createElement('span');
errSpan.style.color = '#bf616a';
errSpan.textContent = "Error: " + res.statusText;
data.appendChild(errSpan);
return;
}
const respJson = await res.json();
if (respJson.error) {
const cursorErr = data.querySelector('.sxng-cursor');
if (cursorErr) cursorErr.remove();
const errSpan = document.createElement('span');
errSpan.style.color = '#bf616a';
errSpan.textContent = "⚠️ " + respJson.error;
data.appendChild(errSpan);
return;
}
const fullText = (respJson.text || '').trim();
if (!fullText) {
const cursorErr = data.querySelector('.sxng-cursor');
if (cursorErr) cursorErr.remove();
const errSpan = document.createElement('span');
errSpan.style.color = '#bf616a';
errSpan.textContent = 'No response received. Check API configuration and server logs.';
data.appendChild(errSpan);
return;
}
let mainText = fullText;
const thinkMatch = mainText.match(/^<think>([\s\S]*?)<\/think>\s*/);
if (thinkMatch) {
const cursorTh = data.querySelector('.sxng-cursor');
const details = document.createElement('details');
details.className = 'sxng-reasoning';
details.innerHTML = '<summary>Thought Process</summary>';
const thoughtDiv = document.createElement('div');
thoughtDiv.className = 'sxng-thought-content';
thoughtDiv.textContent = thinkMatch[1];
details.appendChild(thoughtDiv);
if (cursorTh) cursorTh.before(details);
else data.appendChild(details);
mainText = mainText.substring(thinkMatch[0].length);
}
let cursor = data.querySelector('.sxng-cursor');
if (!cursor) {
cursor = document.createElement('span');
cursor.className = 'sxng-cursor';
data.appendChild(cursor);
}
let buffer = '';
const flushBuffer = (force = false) => {
if (!buffer) return;
if (force) {
const fragment = renderCitations(buffer, urls);
if (cursor) cursor.before(fragment);
else data.appendChild(fragment);
buffer = '';
return;
}
while (true) {
const match = buffer.match(/(\[\d+(?:,\s*\d+)*\])/);
if (!match) break;
const preText = buffer.substring(0, match.index);
if (preText) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = preText;
cursor.before(s);
}
const citationText = match[0];
const fragment = renderCitations(citationText, urls);
cursor.before(fragment);
buffer = buffer.substring(match.index + match[0].length);
}
const openIdx = buffer.lastIndexOf('[');
if (openIdx === -1) {
if (buffer) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = buffer;
cursor.before(s);
buffer = '';
}
} else {
const safeChunk = buffer.substring(0, openIdx);
if (safeChunk) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = safeChunk;
cursor.before(s);
}
buffer = buffer.substring(openIdx);
if (buffer.length > 50) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = buffer[0];
cursor.before(s);
buffer = buffer.substring(1);
}
}
};
let twPos = 0;
const twBatch = 4;
await new Promise(resolve => {
function twTick() {
if (twPos >= mainText.length) {
flushBuffer(true);
resolve();
return;
}
const end = Math.min(twPos + twBatch, mainText.length);
buffer += mainText.substring(twPos, end);
twPos = end;
flushBuffer(false);
setTimeout(twTick, 8);
}
twTick();
});
if (cursor) cursor.remove();
let last = data.lastChild;
while (last) {
if (last.textContent && last.textContent.trim().length === 0) {
const prev = last.previousSibling;
last.remove();
last = prev;
} else {
if (last.textContent) last.textContent = last.textContent.trimEnd();
break;
}
}
renderCitationFooter(mainText, urls, data);
const collectedResponse = mainText;
__INTERACTIVE_JS_COMPLETE__
if (collectedResponse) {
conversation.turns.push({role: 'assistant', content: collectedResponse.trim(), ts: Date.now()});
}
// Save state if this was an initial generation or a regeneration
if (arguments.length === 0 && typeof updateState === 'function') {
updateState();
}
} catch (e) {
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();
data.appendChild(errSpan);
}
} finally {
isStreaming = false;
}
}
if (!restored) startStream();
})();
// === CITATION_HELPER_JS ===
function renderCitations(text, urls) {
const fragment = document.createDocumentFragment();
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');
s.className = 'sxng-chunk';
s.textContent = text.substring(lastIdx, match.index);
fragment.appendChild(s);
}
match[1].split(/\s*,\s*/).forEach(n => {
const idx = parseInt(n.trim());
if (idx >= 1 && idx <= urls.length) {
const url = urls[idx-1];
if (url) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.style.cssText = 'text-decoration:none;color:var(--color-result-link);font-weight:bold;';
a.textContent = `[${n.trim()}]`;
a.className = 'sxng-chunk';
fragment.appendChild(a);
} else {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = `[${n.trim()}]`;
fragment.appendChild(s);
}
} else {
const s = document.createElement('span');
s.className = 'sxng-chunk';
s.textContent = `[${n.trim()}]`;
fragment.appendChild(s);
}
});
lastIdx = match.index + match[0].length;
});
if (lastIdx < text.length) {
const s = document.createElement('span');
s.className = 'sxng-chunk';
// Preserve whitespace by not trimming
s.textContent = text.substring(lastIdx);
fragment.appendChild(s);
}
return fragment;
}
function renderCitationFooter(textContent, urls, container) {
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
const usedIndices = new Set();
let m;
while ((m = re.exec(textContent)) !== null) {
m[1].split(/\s*,\s*/).forEach(n => {
const idx = parseInt(n.trim());
if (idx >= 1 && idx <= urls.length && urls[idx - 1]) {
usedIndices.add(idx);
}
});
}
if (usedIndices.size === 0) return;
const sorted = [...usedIndices].sort((a, b) => a - b);
const footer = document.createElement('div');
footer.className = 'sxng-citation-footer';
sorted.forEach(n => {
const url = urls[n - 1];
if (!url) return;
let domain;
try { domain = new URL(url).hostname.replace('www.', ''); } catch(e) { domain = url; }
const item = document.createElement('span');
item.className = 'sxng-citation-item';
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = `[${n}] ${domain}`;
item.appendChild(a);
footer.appendChild(item);
});
container.appendChild(footer);
}
// === INTERACTIVE_JS ===
const footer = document.getElementById('sxng-footer');
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) {
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 {
let state = {
t: conversation.turns.map(t => ({
r: t.role === 'user' ? 'u' : 'a',
c: t.content.replace(/\s+/g, ' ').trim()
})),
u: urls
};
const encodeB64 = (obj) => {
const u8 = new TextEncoder().encode(JSON.stringify(obj));
let bin = '';
// Use a loop to avoid RangeError: Maximum call stack size exceeded
for (let i = 0; i < u8.byteLength; i++) {
bin += String.fromCharCode(u8[i]);
}
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) {}
};
if (location.hash.includes('ai=')) {
try {
const b64 = location.hash.split('ai=')[1];
const uint8 = new Uint8Array(atob(b64).split('').map(c => c.charCodeAt(0)));
const json = new TextDecoder().decode(uint8);
const state = JSON.parse(json);
if (state.t && state.t.length > 0) {
// Restore URLs for citation indexing
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') {
if (turn.content !== conversation.originalQuery) {
const u = document.createElement('span');
u.className = 'sxng-user-msg';
u.textContent = turn.content;
data.appendChild(u);
const clr = document.createElement('div');
clr.style.clear = 'both';
data.appendChild(clr);
}
} else {
data.appendChild(injectCitations(turn.content));
}
});
box.style.display = 'block';
if(wrapper) wrapper.style.display = '';
if(footer && is_interactive) footer.style.display = 'flex';
restored = true;
}
} catch(e) { console.warn('Restore failed', e); }
}
document.getElementById('btn-copy').onclick = async (e) => {
const btn = e.currentTarget;
const originalContent = btn.innerHTML;
const text = Array.from(data.childNodes)
.filter(n => n.nodeType === 3 || n.tagName === 'SPAN')
.map(n => n.textContent)
.join('');
await navigator.clipboard.writeText(text);
btn.innerHTML = '<svg viewBox="0 0 24 24" style="color:#a3be8c;"><path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z"/></svg>';
setTimeout(() => btn.innerHTML = originalContent, 2000);
};
document.getElementById('btn-regen').onclick = async () => {
data.innerHTML = '<span class="sxng-cursor"></span>';
footer.style.display = 'none';
if (conversation.turns.length > 0 && conversation.turns[conversation.turns.length - 1].role === 'assistant') {
conversation.turns.pop();
}
updateState();
if (conversation.turns.length <= 1) {
await startStream();
} else {
const val = conversation.turns[conversation.turns.length - 1].content;
const currentText = conversation.turns.slice(0, -1).slice(-6)
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
.join('\\n\\n');
await startStream(val, currentText);
}
updateState();
};
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');
input.value = '';
input.blur();
footer.style.display = 'none';
if (val) {
const cursor = data.querySelector('.sxng-cursor');
if (cursor) cursor.remove();
const userMsg = document.createElement('span');
userMsg.className = 'sxng-user-msg';
userMsg.textContent = val;
data.appendChild(userMsg);
const clr = document.createElement('div');
clr.style.clear = 'both';
data.appendChild(clr);
const newCursor = document.createElement('span');
newCursor.className = 'sxng-cursor';
data.appendChild(newCursor);
const synthesized = synthesizeQuery(q_init, val);
let auxContext = null;
try {
const auxData = await fetch(script_root + '/ai-auxiliary-search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: synthesized, lang: lang_init, offset: urls.length, tk: tk_init})
}).then(r => r.json());
if (auxData.context) {
const originalBackground = conversation.originalContext.substring(0, 1500);
auxContext = `FRESH SOURCES (most relevant):\\n${auxData.context}\\n\\nBACKGROUND (for reference):\\n${originalBackground}`;
if (auxData.new_urls && Array.isArray(auxData.new_urls)) {
urls = urls.concat(auxData.new_urls);
}
}
} catch (err) {}
await startStream(val, currentText, auxContext);
updateState();
} else {
const cursor = data.querySelector('.sxng-cursor');
if (cursor) cursor.remove();
data.appendChild(document.createElement('br'));
data.appendChild(document.createElement('br'));
const newCursor = document.createElement('span');
newCursor.className = 'sxng-cursor';
data.appendChild(newCursor);
await startStream("Continue", currentText);
updateState();
}
};
document.getElementById('sxng-action-form').onsubmit = handleAction;
input.onfocus = () => {
setTimeout(() => {
input.scrollIntoView({behavior: 'smooth', block: 'center'});
}, 300);
};
(function fetchModels() {
const _msel2 = document.getElementById('sxng-model-select');
if (!_msel2) return;
const _modelsUrl = script_root + '/ai-models?tk=' + encodeURIComponent(tk_init);
console.log('[AI Answers] Fetching models from', _modelsUrl);
fetch(_modelsUrl)
.then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
.then(d => {
const models = (d && d.models && d.models.length > 0) ? d.models : [model_init];
const _cur = _msel2.value || model_init;
_msel2.innerHTML = '';
models.forEach(m => {
const o = document.createElement('option');
o.value = m; o.textContent = m;
if (m === _cur) o.selected = true;
_msel2.appendChild(o);
});
_msel2.style.display = 'inline-block';
})
.catch(() => {
if (model_init) {
const o = document.createElement('option');
o.value = model_init; o.textContent = model_init;
o.selected = true;
_msel2.appendChild(o);
_msel2.style.display = 'inline-block';
}
});
})();
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Build dist/ollama_answers.py by inlining assets as Python string constants."""
import os
import re
ROOT = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(ROOT, 'assets')
SRC = os.path.join(ROOT, 'ollama_answers.py')
DIST_DIR = os.path.join(ROOT, 'dist')
DIST = os.path.join(DIST_DIR, 'ollama_answers.py')
def read(path):
with open(path, encoding='utf-8') as f:
return f.read()
def main():
css = read(os.path.join(ASSETS, 'ui.css'))
html = read(os.path.join(ASSETS, 'ui.html'))
js_raw = read(os.path.join(ASSETS, 'ui.js'))
frontend_js = js_raw.split('// === CITATION_HELPER_JS ===')[0].replace('// === FRONTEND_JS_TEMPLATE ===', '').strip()
citation_js = js_raw.split('// === CITATION_HELPER_JS ===')[1].split('// === INTERACTIVE_JS ===')[0].strip()
interactive_js = js_raw.split('// === INTERACTIVE_JS ===')[1].strip()
src = read(SRC)
asset_block = '''\
# UI assets
_ASSETS = os.path.join(os.path.dirname(__file__), 'assets')
INTERACTIVE_CSS = open(os.path.join(_ASSETS, 'ui.css')).read()
INTERACTIVE_HTML = open(os.path.join(_ASSETS, 'ui.html')).read()
_js_raw = open(os.path.join(_ASSETS, 'ui.js')).read()
FRONTEND_JS_TEMPLATE = _js_raw.split('// === CITATION_HELPER_JS ===')[0].replace('// === FRONTEND_JS_TEMPLATE ===', '').strip()
CITATION_HELPER_JS = _js_raw.split('// === CITATION_HELPER_JS ===')[1].split('// === INTERACTIVE_JS ===')[0].strip()
INTERACTIVE_JS = _js_raw.split('// === INTERACTIVE_JS ===')[1].strip()'''
inline_block = (
'# UI assets\n'
'INTERACTIVE_CSS = """\\\n' + css.replace('\\', '\\\\') + '\n"""\n'
'INTERACTIVE_HTML = """\\\n' + html.replace('\\', '\\\\') + '\n"""\n'
'FRONTEND_JS_TEMPLATE = r"""\\\n' + frontend_js + '\n"""\n'
'CITATION_HELPER_JS = r"""\\\n' + citation_js + '\n"""\n'
'INTERACTIVE_JS = r"""\\\n' + interactive_js + '\n"""\n'
)
if asset_block not in src:
print('ERROR: Could not locate the asset-loading block in ollama_answers.py')
raise SystemExit(1)
dist_src = src.replace(asset_block, inline_block)
dist_src = '# AUTO-GENERATED by build.py — do not edit directly\n' + dist_src
os.makedirs(DIST_DIR, exist_ok=True)
with open(DIST, 'w', encoding='utf-8') as f:
f.write(dist_src)
print('Built dist/ollama_answers.py successfully')
if __name__ == '__main__':
main()
+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
+1416 -280
View File
File diff suppressed because it is too large Load Diff
-242
View File
@@ -1,242 +0,0 @@
#!/usr/bin/env bash
set -e
REPO_URL="https://github.com/TySP-Dev/ollama-ai-answers-searxng"
REPO_DIR="/tmp/ollama-ai-answers-searxng"
# ============================================================
echo "=== Step 1: Clone or update repo ==="
# ============================================================
if [ -d "$REPO_DIR/.git" ]; then
echo "Repository already exists — pulling latest changes..."
git -C "$REPO_DIR" pull
else
git clone "$REPO_URL" "$REPO_DIR"
fi
cd "$REPO_DIR"
# ============================================================
echo "=== Step 2: Build dist file ==="
# ============================================================
if ! python3 build.py; then
echo "ERROR: build.py failed"
exit 1
fi
# ============================================================
echo "=== Step 3: Detect SearXNG Docker Compose installation ==="
# ============================================================
CANDIDATES=(
"$HOME/searxng/docker-compose.yml"
"$HOME/docker/searxng/docker-compose.yml"
"/opt/searxng/docker-compose.yml"
"/etc/searxng/docker-compose.yml"
)
FOUND=()
for f in "${CANDIDATES[@]}"; do
if [ -f "$f" ] && grep -q "searxng" "$f" 2>/dev/null; then
FOUND+=("$f")
fi
done
# Also do a broader search
while IFS= read -r f; do
# Avoid duplicates
already=false
for existing in "${FOUND[@]}"; do
[ "$f" = "$existing" ] && already=true && break
done
$already || FOUND+=("$f")
done < <(find "$HOME" -maxdepth 6 -name "docker-compose.yml" -exec grep -l "searxng" {} \; 2>/dev/null)
if [ ${#FOUND[@]} -eq 0 ]; then
echo "ERROR: No docker-compose.yml containing 'searxng' was found."
echo "Please set COMPOSE_FILE manually and re-run."
exit 1
elif [ ${#FOUND[@]} -eq 1 ]; then
COMPOSE_FILE="${FOUND[0]}"
echo "Found: $COMPOSE_FILE"
else
echo "Multiple docker-compose.yml files found:"
for i in "${!FOUND[@]}"; do
echo " [$((i+1))] ${FOUND[$i]}"
done
read -rp "Select a file [1-${#FOUND[@]}]: " choice
idx=$((choice - 1))
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#FOUND[@]}" ]; then
echo "ERROR: Invalid selection"
exit 1
fi
COMPOSE_FILE="${FOUND[$idx]}"
fi
COMPOSE_DIR="$(dirname "$COMPOSE_FILE")"
echo "Using: $COMPOSE_FILE"
# ============================================================
echo "=== Step 4: Create plugins directory ==="
# ============================================================
PLUGINS_DIR="$COMPOSE_DIR/plugins"
mkdir -p "$PLUGINS_DIR"
echo "Plugins directory: $PLUGINS_DIR"
# ============================================================
echo "=== Step 5: Copy built plugin ==="
# ============================================================
cp dist/ollama_answers.py "$PLUGINS_DIR/ollama_answers.py"
echo "Plugin installed to $PLUGINS_DIR/ollama_answers.py"
# ============================================================
echo "=== Step 6: Update docker-compose.yml volume mount ==="
# ============================================================
python3 - "$COMPOSE_FILE" <<'PYEOF'
import re, sys
compose_file = sys.argv[1]
content = open(compose_file).read()
mount = ' - ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z'
if 'ollama_answers.py' not in content:
content = re.sub(
r'(volumes:(?:\n\s+- [^\n]+)*)',
lambda m: m.group(0) + '\n' + mount,
content, count=1
)
open(compose_file, 'w').write(content)
print('Added volume mount to docker-compose.yml')
else:
print('Volume mount already present')
PYEOF
# ============================================================
echo "=== Step 7: Find and update settings.yml ==="
# ============================================================
SETTINGS_CANDIDATES=(
"$COMPOSE_DIR/core-config/settings.yml"
"$COMPOSE_DIR/config/settings.yml"
"$COMPOSE_DIR/searxng/settings.yml"
)
SETTINGS_FOUND=()
for f in "${SETTINGS_CANDIDATES[@]}"; do
[ -f "$f" ] && SETTINGS_FOUND+=("$f")
done
# Broader search within COMPOSE_DIR
while IFS= read -r f; do
already=false
for existing in "${SETTINGS_FOUND[@]}"; do
[ "$f" = "$existing" ] && already=true && break
done
$already || SETTINGS_FOUND+=("$f")
done < <(find "$COMPOSE_DIR" -name "settings.yml" 2>/dev/null | head -5)
if [ ${#SETTINGS_FOUND[@]} -eq 0 ]; then
echo "WARNING: settings.yml not found. Add this manually:"
echo ""
echo " plugins:"
echo " searx.plugins.ollama_answers.SXNGPlugin:"
echo " active: true"
echo ""
SETTINGS_FILE=""
elif [ ${#SETTINGS_FOUND[@]} -eq 1 ]; then
SETTINGS_FILE="${SETTINGS_FOUND[0]}"
echo "Found: $SETTINGS_FILE"
else
echo "Multiple settings.yml files found:"
for i in "${!SETTINGS_FOUND[@]}"; do
echo " [$((i+1))] ${SETTINGS_FOUND[$i]}"
done
read -rp "Select a file [1-${#SETTINGS_FOUND[@]}]: " schoice
sidx=$((schoice - 1))
if [ "$sidx" -lt 0 ] || [ "$sidx" -ge "${#SETTINGS_FOUND[@]}" ]; then
echo "ERROR: Invalid selection"
exit 1
fi
SETTINGS_FILE="${SETTINGS_FOUND[$sidx]}"
fi
if [ -n "$SETTINGS_FILE" ]; then
python3 - "$SETTINGS_FILE" <<'PYEOF'
import sys
settings_file = sys.argv[1]
content = open(settings_file).read()
entry = 'searx.plugins.ollama_answers.SXNGPlugin:\n active: true'
if 'ollama_answers' not in content:
if 'plugins:' in content:
content = content.replace('plugins:', 'plugins:\n\n ' + entry, 1)
else:
content += '\nplugins:\n\n ' + entry + '\n'
open(settings_file, 'w').write(content)
print('Added plugin entry to settings.yml')
else:
print('Plugin entry already present')
PYEOF
fi
# ============================================================
echo "=== Step 8: Configure environment variables ==="
# ============================================================
python3 - "$COMPOSE_FILE" <<'PYEOF'
import sys
compose_file = sys.argv[1]
content = open(compose_file).read()
has_llm_url = 'LLM_URL' in content
has_llm_model = 'LLM_MODEL' in content
sys.exit(0 if (has_llm_url and has_llm_model) else 1)
PYEOF
ENV_ALREADY=$?
if [ $ENV_ALREADY -eq 0 ]; then
echo "LLM_URL and LLM_MODEL already present in docker-compose.yml"
else
read -rp "Enter Ollama URL (default: http://ollama:11434/v1/chat/completions): " llm_url
llm_url="${llm_url:-http://ollama:11434/v1/chat/completions}"
read -rp "Enter model name (default: qwen3.5:9b): " llm_model
llm_model="${llm_model:-qwen3.5:9b}"
python3 - "$COMPOSE_FILE" "$llm_url" "$llm_model" <<'PYEOF'
import re, sys
compose_file, llm_url, llm_model = sys.argv[1], sys.argv[2], sys.argv[3]
content = open(compose_file).read()
env_lines = f' - LLM_URL={llm_url}\n - LLM_MODEL={llm_model}'
if 'LLM_URL' not in content:
content = re.sub(
r'(environment:(?:\n\s+- [^\n]+)*)',
lambda m: m.group(0) + '\n' + env_lines,
content, count=1
)
open(compose_file, 'w').write(content)
print(f'Added LLM_URL={llm_url} and LLM_MODEL={llm_model} to docker-compose.yml')
else:
print('LLM_URL already present, skipping')
PYEOF
fi
# ============================================================
echo "=== Step 9: Restart SearXNG ==="
# ============================================================
read -rp "Restart SearXNG now? (y/n): " restart_choice
if [[ "$restart_choice" =~ ^[Yy]$ ]]; then
echo "Restarting SearXNG..."
cd "$COMPOSE_DIR"
docker compose up -d --force-recreate core
echo "SearXNG restarted."
else
echo "Skipping restart. Run manually:"
echo " cd \"$COMPOSE_DIR\" && docker compose up -d --force-recreate core"
fi
# ============================================================
echo ""
echo "=== Installation complete ==="
echo ""
echo "Summary:"
echo " Plugin: $PLUGINS_DIR/ollama_answers.py"
echo " Compose: $COMPOSE_FILE"
[ -n "$SETTINGS_FILE" ] && echo " Settings: $SETTINGS_FILE"
echo ""
echo "Environment variables to verify in docker-compose.yml:"
echo " LLM_URL — Ollama endpoint"
echo " LLM_MODEL — Model name (e.g. qwen3.5:9b)"
echo ""
+1520 -124
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
flask flask
flask-babel flask-babel
certifi certifi
python-dotenv
valkey
-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)