Making the code base easy to read and maintain and making it a oneline install
This commit is contained in:
@@ -6,3 +6,4 @@ venv/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.agent/
|
.agent/
|
||||||
|
dist/
|
||||||
@@ -1,32 +1,75 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://git.tysstech.com/tyler/PacCrypt-Webapp)
|
# ollama-ai-answers-searxng
|
||||||
[](https://github.com/TySP-Dev/ollama-ai-answers-searxng)
|
|
||||||
|
|
||||||
<div align="left">
|
**Local AI search overviews for SearXNG, powered by Ollama.**
|
||||||
|
|
||||||
# Ollama AI Answers Plugin for SearXNG
|

|
||||||
**Based on [ai-answers-searxng](https://github.com/cra88y/ai-answers-searxng) by [cra88y](https://github.com/cra88y)**
|

|
||||||
|

|
||||||
|
|
||||||
A SearXNG plugin that generates local AI overviews powered by Ollama, using search results as RAG context.
|
[](https://github.com/TySP-Dev/ollama-ai-answers-searxng)
|
||||||
|
|
||||||
Features:
|
</div>
|
||||||
- Token-by-token UI streaming
|
|
||||||
- Clickable inline citations
|
|
||||||
- Interactive mode: continue summary, ask follow-ups, copy, or regenerate
|
|
||||||
- Simple response mode with no extras
|
|
||||||
- Internally called low-latency RAG for follow-ups (bypasses HTTP loopback)
|
|
||||||
- Native network integration via `searx.network` (respects proxy/SSL settings)
|
|
||||||
- Stateless conversation persistence/shareability via URL hash
|
|
||||||
- Model selector in the AI overview widget
|
|
||||||
- Does not slow down result loading
|
|
||||||
- One file install
|
|
||||||
|
|
||||||
## Installation
|
## One-line Install
|
||||||
|
|
||||||
Place `ollama_answers.py` into the `searx/plugins` directory of your SearXNG instance (or mount it in a container) and enable it in `settings.yml`:
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/master/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- AI Overview box at the top of every search result page
|
||||||
|
- Powered entirely by your local Ollama instance — no external API calls
|
||||||
|
- Page content fetching — enriches context beyond SearXNG snippets
|
||||||
|
- Model selector dropdown — switch models per-search without restarting
|
||||||
|
- Inline citations with clickable source links
|
||||||
|
- Citation footer listing all referenced sources
|
||||||
|
- Follow-up questions with conversation history
|
||||||
|
- Copy and Regenerate buttons
|
||||||
|
- Typewriter animation (granian-compatible buffered response)
|
||||||
|
- Ollama-only — no OpenAI, Gemini, or other provider bloat
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- SearXNG installed via Docker Compose
|
||||||
|
- Ollama running and accessible from the SearXNG container
|
||||||
|
- Python 3.8+ (for `build.py` and `install.sh`)
|
||||||
|
- Docker + Docker Compose
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### One-line (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/TySP-Dev/ollama-ai-answers-searxng/master/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/TySP-Dev/ollama-ai-answers-searxng
|
||||||
|
cd ollama-ai-answers-searxng
|
||||||
|
python3 build.py
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually copy the built plugin and update your config:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# docker-compose.yml — searxng service
|
||||||
|
environment:
|
||||||
|
- LLM_URL=http://ollama:11434/v1/chat/completions
|
||||||
|
- LLM_MODEL=qwen3.5:9b
|
||||||
|
volumes:
|
||||||
|
- ./plugins/ollama_answers.py:/usr/local/searxng/searx/plugins/ollama_answers.py:Z
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# settings.yml
|
||||||
plugins:
|
plugins:
|
||||||
searx.plugins.ollama_answers.SXNGPlugin:
|
searx.plugins.ollama_answers.SXNGPlugin:
|
||||||
active: true
|
active: true
|
||||||
@@ -34,102 +77,73 @@ plugins:
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configure via environment variables.
|
All configuration is done via environment variables on the SearXNG container.
|
||||||
|
|
||||||
### Required
|
| Variable | Default | Description |
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LLM_URL` | Ollama chat completions endpoint | `http://ollama:11434/v1/chat/completions` |
|
| `LLM_URL` | `http://ollama:11434/v1/chat/completions` | Ollama endpoint |
|
||||||
| `LLM_MODEL` | Model name as listed in Ollama | `qwen3.5:9b` |
|
| `LLM_MODEL` | `qwen3.5:9b` | Default model |
|
||||||
|
| `LLM_MAX_TOKENS` | `200` | Max response tokens |
|
||||||
|
| `LLM_TEMPERATURE` | `0.2` | Response temperature |
|
||||||
|
| `LLM_TABS` | `general,science,it,news` | Search tabs to show AI overview on |
|
||||||
|
| `LLM_QUESTION_MARK_REQUIRED` | `false` | Only trigger on queries ending with `?` |
|
||||||
|
| `LLM_INTERACTIVE` | `true` | Show copy/regenerate/follow-up UI |
|
||||||
|
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Override the system prompt |
|
||||||
|
| `LLM_CONTEXT_DEEP_COUNT` | `5` | Results fetched for full page content |
|
||||||
|
| `LLM_CONTEXT_SHALLOW_COUNT` | `15` | Results used as headline-only context |
|
||||||
|
|
||||||
### Optional
|
## Project Structure
|
||||||
|
|
||||||
| Variable | Description | Default |
|
```
|
||||||
|---|---|---|
|
ollama-ai-answers-searxng/
|
||||||
| `LLM_SYSTEM_PROMPT` | Overrides the default system prompt | `You are a direct, citation-accurate search synthesis engine.` |
|
├── ollama_answers.py # Source plugin — reads UI from assets/
|
||||||
| `LLM_MAX_TOKENS` | Max tokens in the AI response | `200` |
|
├── build.py # Assembles dist/ollama_answers.py (self-contained)
|
||||||
| `LLM_TEMPERATURE` | Sampling temperature | `0.2` |
|
├── install.sh # Full automated Docker Compose installer
|
||||||
| `LLM_CONTEXT_DEEP_COUNT` | Results used with full snippets | `5` |
|
├── assets/
|
||||||
| `LLM_CONTEXT_SHALLOW_COUNT` | Results with headlines only (breadth) | `15` |
|
│ ├── ui.css # Interactive widget styles
|
||||||
| `LLM_TABS` | Comma-delimited tab whitelist | `general,science,it,news` |
|
│ ├── ui.html # Interactive widget HTML (copy/regen/follow-up bar)
|
||||||
| `LLM_INTERACTIVE` | Interactive UI mode (copy, regenerate, follow-up) | `true` |
|
│ └── ui.js # Frontend JS (typewriter, citations, streaming)
|
||||||
| `LLM_QUESTION_MARK_REQUIRED` | Only trigger on queries containing `?` | `false` |
|
├── dist/ # Output of build.py — gitignored
|
||||||
|
│ └── ollama_answers.py # Self-contained, ready to deploy
|
||||||
## How It Works
|
├── dev/
|
||||||
|
│ └── dev.py # Local Flask dev server (no SearXNG required)
|
||||||
1. User performs a search
|
└── README.md
|
||||||
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
|
|
||||||
|
|
||||||
## Docker Compose Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
searxng:
|
|
||||||
environment:
|
|
||||||
- LLM_URL=http://ollama:11434/v1/chat/completions
|
|
||||||
- LLM_MODEL=qwen3.5:9b
|
|
||||||
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
|
## Development
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
||||||
pip install flask flask-babel certifi
|
# Edit source files
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
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.
|
||||||
|
|
||||||
```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.
|
||||||
|
|
||||||
### Usage
|
## How It Works
|
||||||
|
|
||||||
- Type a query in the search bar and hit **Search** to trigger an AI overview.
|
1. User searches on SearXNG
|
||||||
- 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.
|
2. `post_search` hook fires after results are fetched
|
||||||
- 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.
|
3. Top result URLs are fetched in parallel for full page content
|
||||||
|
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
|
||||||
|
|
||||||
### Environment Variables (dev)
|
## License
|
||||||
|
|
||||||
The dev reads the same variables as the plugin:
|
MIT License
|
||||||
|
|
||||||
```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
@@ -0,0 +1,134 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<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
@@ -0,0 +1,566 @@
|
|||||||
|
// === 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/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()
|
||||||
+346
@@ -0,0 +1,346 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import logging
|
||||||
|
from types import ModuleType
|
||||||
|
from flask import Flask, request, redirect
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
|
os.environ.setdefault('LLM_URL', 'http://localhost:11434/v1/chat/completions')
|
||||||
|
|
||||||
|
# SearXNG module mocks
|
||||||
|
searx = ModuleType("searx")
|
||||||
|
searx_plugins = ModuleType("searx.plugins")
|
||||||
|
searx_results = ModuleType("searx.result_types")
|
||||||
|
|
||||||
|
class MockPlugin:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.active = getattr(cfg, 'active', True)
|
||||||
|
|
||||||
|
class MockPluginInfo:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.meta = kwargs
|
||||||
|
|
||||||
|
class MockEngineResults:
|
||||||
|
def __init__(self):
|
||||||
|
self.types = ModuleType("types")
|
||||||
|
self.types.Answer = lambda *args, **kwargs: kwargs.get('answer', args[0] if args else "")
|
||||||
|
self._results = []
|
||||||
|
|
||||||
|
def add(self, res):
|
||||||
|
self._results.append(res)
|
||||||
|
|
||||||
|
searx_plugins.Plugin = MockPlugin
|
||||||
|
searx_plugins.PluginInfo = MockPluginInfo
|
||||||
|
searx_results.EngineResults = MockEngineResults
|
||||||
|
|
||||||
|
searx.settings = {'server': {'secret_key': 'demo-secret'}}
|
||||||
|
searx.network = ModuleType("searx.network")
|
||||||
|
|
||||||
|
sys.modules["searx"] = searx
|
||||||
|
sys.modules["searx.plugins"] = searx_plugins
|
||||||
|
sys.modules["searx.result_types"] = searx_results
|
||||||
|
|
||||||
|
# Network module mock
|
||||||
|
searx_network = ModuleType("searx.network")
|
||||||
|
def mock_network_call(method, url, **kwargs):
|
||||||
|
import http.client, ssl, json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
port = parsed.port or (443 if parsed.scheme=='https' else 80)
|
||||||
|
target = f"{parsed.hostname}:{port}"
|
||||||
|
|
||||||
|
if parsed.scheme == 'https':
|
||||||
|
conn = http.client.HTTPSConnection(target, timeout=30, context=ssl.create_default_context())
|
||||||
|
else:
|
||||||
|
conn = http.client.HTTPConnection(target, timeout=30)
|
||||||
|
|
||||||
|
headers = kwargs.get('headers', {})
|
||||||
|
body = None
|
||||||
|
if kwargs.get('json'):
|
||||||
|
body = json.dumps(kwargs['json'])
|
||||||
|
elif kwargs.get('data'):
|
||||||
|
body = kwargs['data']
|
||||||
|
|
||||||
|
path = parsed.path
|
||||||
|
if parsed.query:
|
||||||
|
path += f"?{parsed.query}"
|
||||||
|
|
||||||
|
if kwargs.get('params'):
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
query_str = urlencode(kwargs['params'])
|
||||||
|
if '?' in path:
|
||||||
|
path += f"&{query_str}"
|
||||||
|
else:
|
||||||
|
path += f"?{query_str}"
|
||||||
|
|
||||||
|
conn.request(method, path, body=body, headers=headers)
|
||||||
|
return conn.getresponse()
|
||||||
|
|
||||||
|
def mock_stream(method, url, **kwargs):
|
||||||
|
res = mock_network_call(method, url, **kwargs)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, r):
|
||||||
|
self.status_code = r.status
|
||||||
|
self.text = "Mock Response" # Stub
|
||||||
|
self._r = r
|
||||||
|
|
||||||
|
def generator():
|
||||||
|
while True:
|
||||||
|
chunk = res.read(128)
|
||||||
|
if not chunk: break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return MockResponse(res), generator()
|
||||||
|
|
||||||
|
def mock_get(url, **kwargs):
|
||||||
|
import json
|
||||||
|
res = mock_network_call('GET', url, **kwargs)
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, r):
|
||||||
|
self.status_code = r.status
|
||||||
|
self._content = r.read()
|
||||||
|
self.text = self._content.decode('utf-8')
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return json.loads(self.text)
|
||||||
|
|
||||||
|
return MockResponse(res)
|
||||||
|
|
||||||
|
searx_network.stream = mock_stream
|
||||||
|
searx_network.get = mock_get
|
||||||
|
sys.modules["searx.network"] = searx_network
|
||||||
|
|
||||||
|
from 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)
|
||||||
Executable
+242
@@ -0,0 +1,242 @@
|
|||||||
|
#!/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 ""
|
||||||
+7
-725
@@ -52,731 +52,13 @@ PLUGIN_NAME = "AI Answers"
|
|||||||
DEFAULT_TABS = "general,science,it,news"
|
DEFAULT_TABS = "general,science,it,news"
|
||||||
|
|
||||||
# UI assets
|
# UI assets
|
||||||
|
_ASSETS = os.path.join(os.path.dirname(__file__), 'assets')
|
||||||
INTERACTIVE_CSS = '''
|
INTERACTIVE_CSS = open(os.path.join(_ASSETS, 'ui.css')).read()
|
||||||
@keyframes sxng-fade-in-up {
|
INTERACTIVE_HTML = open(os.path.join(_ASSETS, 'ui.html')).read()
|
||||||
0% { opacity: 0; transform: translateY(10px); }
|
_js_raw = open(os.path.join(_ASSETS, 'ui.js')).read()
|
||||||
100% { opacity: 1; transform: translateY(0); }
|
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()
|
||||||
.sxng-footer {
|
INTERACTIVE_JS = _js_raw.split('// === INTERACTIVE_JS ===')[1].strip()
|
||||||
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;
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
|
|
||||||
INTERACTIVE_HTML = '''
|
|
||||||
<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>
|
|
||||||
'''
|
|
||||||
|
|
||||||
CITATION_HELPER_JS = r'''
|
|
||||||
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 = r'''
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
'''
|
|
||||||
|
|
||||||
FRONTEND_JS_TEMPLATE = r"""
|
|
||||||
(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();
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
|
|||||||
Reference in New Issue
Block a user