B8: Private Chat mode + WireGuard docs + README data safety section (v0.19.3)

This commit is contained in:
gramps
2026-07-14 08:52:32 -07:00
parent 4f16a9c078
commit f6b01ec9ac
9 changed files with 457 additions and 77 deletions
+35 -7
View File
@@ -1,6 +1,6 @@
![cAIc banner](static/readme-banner.png)
# cAIc v0.19.2
# cAIc v0.19.3
Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature.
@@ -38,16 +38,41 @@ cAIc takes a different approach: **query-routing**. Each worker runs a complete
This also means a worker with a slow GPU can still contribute meaningfully — it handles less latency-sensitive queries or batch background work, while the fast GPU handles interactive chat.
### Data Safety
| Concern | How cAIc handles it |
|---------|---------------------|
| **Queries stored on disk?** | Yes, by default — conversations persist to SQLite. Toggle **Private Chat** (topbar badge) and nothing touches disk: no SQLite writes, no FTS5 memory injection, no RAG ingestion, no external SearXNG queries. |
| **Queries sent to external services?** | SearXNG web search is optional and disabled in Private Chat. All other services (llama-server, Qdrant, RabbitMQ) run on your own LAN. |
| **Inter-node traffic unencrypted?** | No — WireGuard tunnels encrypt all coordinator↔worker traffic (AMQP, inference, RPC) at the network layer. Zero application changes. |
| **Who can access the server?** | Guest sessions for anyone on the LAN. Admin access protected by a PBKDF2-hashed 4-digit PIN with rate-limited attempts. IP allowlist (CIDR) gate optional. |
At v1.0, this ships with a Docker compose stack and setup wizard that detect CPU vs GPU, probe your hardware, and stand up SearXNG, Qdrant, RabbitMQ, and everything else with a single `docker compose up`. The same install docs work bare-metal for those who prefer to skip containers entirely.
Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture)
## What's New in v0.19.3
### Private Chat Mode (B8)
- **PRIVATE badge** — topbar toggle switches to private mode where nothing is persisted, no memory/RAG is injected, and web search is disabled
- **Info popup** — click the (i) icon next to the badge for a full explanation of what private mode does and doesn't do
- **No-storage guarantee** — conversation is streamed to the user but never touches SQLite, FTS5, or Qdrant
- **Search blocked** — `/api/search` returns 403 in private mode; WEB button is disabled in the UI
### WireGuard In-Transit Encryption
- WireGuard tunnels encrypt all inter-node traffic. See Data Safety section above.
## What's New in v0.19.2
### Waterfall Direction Toggle (B6)
### Waterfall Direction Toggle (B6) + UX Polish
- **NEW/OLD toggle** — topbar button switches between newest-first (waterfall) and oldest-first (traditional chat)
- **Direction-aware scroll** — newest-first scrolls to top, oldest-first scrolls to bottom
- **Direction-aware scroll** — newest-first scrolls to top, oldest-first scrolls to bottom; respects user scroll-away to avoid fighting
- **localStorage persistence** — preference survives page reloads, default is newest-first (waterfall)
- **Toast notifications** — slide-out notifications for copy, save, delete, rate actions
- **Clipboard reliability** — `execCopy()` helper for HTTP fallback (`document.execCommand`) when `navigator.clipboard` fails on plain HTTP
- **Model label** — `modelLabel()` derives shorthand display names (e.g. `qwen2.5:7B:i`)
- **Keybinding fix** — Shift+Enter = newline, Ctrl+Enter = send (universal conventions)
- **Token counter** — resets to 0 on page refresh, no longer persisted to localStorage
## What's New in v0.19.1
@@ -150,6 +175,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
- **Conversation History** — SQLite-backed chat persistence with mass-delete option
- **Model Switching** — Change inference models on the fly
- **Skills Framework** — Built-in skill registry with per-skill enable/disable controls
- **Private Chat** — Toggle to keep conversations ephemeral: no persistence, no memory/RAG, no web search
## File Structure
@@ -162,12 +188,13 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
├── config.py # Constants, env vars, limits, skill registry
├── db.py # SQLite schema, connection factory
├── eviction.py # Score-based RAG eviction engine
├── gpu.py # AMD GPU stats via rocm-smi
├── hardware.py # Hardware self-assessment (CPU, RAM, VRAM)
├── gpu.py # GPU stats rocm-smi (Linux/AMD) + system_profiler (Darwin/Apple Silicon)
├── hardware.py # Hardware self-assessment (CPU, RAM, VRAM) — Linux + Darwin
├── memory.py # FTS5 memory CRUD, remember/forget commands
├── rag.py # Qdrant vector search + system prompt assembly
├── search.py # SearXNG integration, perplexity, refusal detection
├── security.py # Rate limiting, origin checks, IP allowlist, audit
├── model_pull.py # Startup model auto-pull (llama-server → Ollama fallback)
├── triage.py # Query classification + cluster node selection
├── routers/
│ ├── chat.py # /api/chat streaming endpoint
@@ -190,7 +217,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
├── node_agent/
│ ├── agent.py # Standalone worker agent (AMQP client)
│ └── requirements.txt
└── tests/ # 179 pytest tests
└── tests/ # 198 pytest tests
```
## Requirements
@@ -200,6 +227,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
- SearXNG (optional, for web search)
- RabbitMQ (optional, for AMQP cluster — coordinator only)
- Qdrant (optional, for RAG vector search)
- WireGuard (optional, for encrypted inter-node transit — see [WireGuard-Setup.md](docs/wiki/WireGuard-Setup.md))
## Installation
@@ -404,7 +432,7 @@ Settings are stored in the `settings` table and include:
python3 -m pytest tests/ -v
```
All 179 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed.
All 198 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed.
## License
+51 -2
View File
@@ -479,6 +479,48 @@ caddy:
This is out of scope for v1.0 but documented for future.
### 5.4 WireGuard tunnel (off-site workers)
When a worker node runs on a different network (colo, friend's house, VPS), all cross-site traffic must be encrypted. WireGuard provides this at the network layer with zero application changes.
**Approach:** Install WireGuard on the Docker host (not inside a container). The host creates a tunnel interface (`wg0`) with a virtual IP in the `10.0.2.0/24` range. Containers that need to reach remote workers use the host's WireGuard IP via `network_mode: host` or standard routing.
```
Off-site worker Docker host (coordinator)
┌────────────────────┐ ┌───────────────────────────────┐
│ wg0: 10.0.2.2 │◄───UDP──────│ wg0: 10.0.2.1 │
│ llama-server │ :51820 │ │
│ node_agent.py │ encrypts │ ┌───────────────────────┐ │
│ │ all │ │ cAIc container │ │
│ │ traffic │ │ LLAMA_SERVER_BASE │ │
│ │ │ │ → 10.0.2.2:8081 │ │
│ │ │ │ CAIC_AMQP_URL │ │
│ │ │ │ → amqp://caic:@... │ │
│ │ │ └───────────────────────┘ │
└────────────────────┘ └───────────────────────────────┘
```
**Host setup (coordinator):**
```bash
sudo apt install wireguard
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key
```
Then create `/etc/wireguard/wg0.conf` — see [WireGuard-Setup.md](WireGuard-Setup.md) for full per-node configs.
**Container networking:** The cAIc container needs to reach the WireGuard IP. Options:
1. **`network_mode: host`** — simplest, container shares host network stack. Done by adding `network_mode: host` to the cAIc service. Trade-off: no port isolation.
2. **Host routing** — the host's kernel routes `10.0.2.0/24` via `wg0`. Containers on the default bridge or compose network can reach those IPs if `ip_forward` is enabled. This works out of the box on Linux.
**cAIc env vars after WireGuard:**
```env
# Point at worker's WireGuard IP instead of LAN IP
LLAMA_SERVER_BASE=http://10.0.2.2:8081 # falls back to coordinator's own llama-server
CAIC_AMQP_URL=amqp://caic:password@10.0.2.1:5672/caic # coordinator's RMQ on WG IP
```
The node agent on each worker configures its registration IP as the WireGuard tunnel IP (`node_ip = 10.0.2.2`), so `triage.py` constructs inference URLs pointing at the encrypted interface.
---
## 6. Setup Wizard (Extraction)
@@ -642,6 +684,7 @@ If the setup wizard fails mid-way, a partial rollback is better than leaving det
| **Reverse proxy** | Caddy is simplest for auto-HTTPS. Out of scope for v1.0 but design for it. | Low |
| **Healthcheck strategy** | `depends_on` with `condition: service_healthy` is the safest approach but increases startup time. Acceptable. | Medium |
| **Database migration** | SQLite file in volume — no migration needed for v1.0 format. If schema changes post-v1.0, need a migration container. | Low |
| **WireGuard integration** | Documented in docker.md §5.4 + wiki. Host-level install; no container changes needed. WireGuard sidecar container (`linuxserver/wireguard`) is an alternative for users who want everything in compose. | Low |
| **Linux vs macOS vs Windows** | Linux-primary. macOS may work with changes (no rocm-smi). Windows via WSL2 only. | Low |
| **LLM model download** | HuggingFace CLI integration in setup.sh, or manual download. Manual is simpler. | Low |
| **Dockerfile optimization** | Pin pip hashes, use `--no-cache-dir`, consider `slim` vs `alpine`. Alpine has musl compatibility issues with psutil. Stay with slim. | Medium |
@@ -684,6 +727,10 @@ Worker machine (e.g. worker01, worker02)
### 9.3 Worker setup
```bash
# Install WireGuard (required for off-site workers — encrypts all traffic)
sudo apt install wireguard
# See docs/wiki/WireGuard-Setup.md for per-node config
# Install llama-server binary
wget https://github.com/ggml-org/llama.cpp/releases/.../llama-server
chmod +x llama-server
@@ -691,8 +738,9 @@ chmod +x llama-server
# Install node agent deps
pip install aio-pika httpx
# Create node agent script (from repo: node_agent/agent.py)
# Configure COORDINATOR_AMQP_URL in environment
# Create node agent config: /etc/caic-node-agent.conf
# Set node_ip to the WireGuard tunnel IP (e.g., 10.0.2.2)
# Set amqp_url to the coordinator's WireGuard IP (e.g., amqp://caic:password@10.0.2.1:5672/caic)
```
### 9.4 Multiple workers
@@ -728,6 +776,7 @@ The broker-mediated model is the preferred architecture for this project because
- [ ] `docker compose up -d` works without any manual steps beyond setup.sh
- [ ] `docker compose down -v` followed by `setup.sh && docker compose up -d` = fresh stack
- [ ] Healthchecks prevent serving before dependencies are ready
- [ ] WireGuard tunnel documented and tested for off-site workers
- [ ] v1.0 release tag created
---
+6 -4
View File
@@ -265,25 +265,28 @@ All streaming endpoints yield `data: {json}\n\n`:
- No live external services required
- Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals per test
### 8.2 Test Coverage Areas (179 tests)
### 8.2 Test Coverage Areas (198 tests)
| Test file | Coverage |
|-----------|----------|
| test_auth_capabilities.py | Guest/admin sessions, origin blocking, logout |
| test_chat_streaming_and_memory_paths.py | Streaming, auto-search, remember/forget, upload context injection |
| test_chat_streaming_and_memory_paths.py | Streaming, auto-search, remember/forget, upload context injection, private chat |
| test_cluster.py | Registration, deregistration, pong, events, coordinator query |
| test_cluster_heartbeat.py | Heartbeat handler, known/unknown node |
| test_completions.py | API key auth, FIM, streaming, blocking, errors |
| test_conversations.py | Full CRUD, guest admin, attachment_count |
| test_error_envelopes.py | Global exception handler + stream errors |
| test_gpu.py | GPU stats — rocm-smi (Linux), system_profiler (Darwin/Apple Silicon) |
| test_ingest.py | Bearer auth, chunk/embed/upsert, validation |
| test_ip_allowlist.py | IP allowlist helper + middleware |
| test_memories.py | Edit, search, stats |
| test_model_pull.py | Default model auto-pull — llama-server check, Ollama fallback, error paths |
| test_model_swap.py | request_model_swap, handle_model_ready/failed, select_node swap triggering |
| test_models_router.py | Models list, ps, show, stats, search/status |
| test_node_agent.py | Node agent registration, ping/pong, model swap |
| test_presets.py | Full CRUD, default preset protection |
| test_profile.py | Get, update, default, length validation |
| test_rag_management.py | Collection stats, eviction algorithm (pinned/grace/scoring/batch), maybe_evict hysteresis, operational stats, flush, concurrency lock |
| test_rag_management.py | Collection stats, eviction algorithm, hysteresis, flush |
| test_rate_and_payload_guardrails.py | Rate limits + payload size |
| test_search_route.py | Explicit search flow, no results, errors |
| test_search_url_sanitization.py | URL sanitizer |
@@ -291,7 +294,6 @@ All streaming endpoints yield `data: {json}\n\n`:
| test_skills_framework.py | List, toggle, unknown skill, prompt injection |
| test_triage.py | classify_query, select_node, get_inference_url |
| test_upload.py | Upload, delete, link, by-conversation, attachment_count |
| test_error_envelopes.py | Global exception handler + stream errors |
### 8.3 DoD Process
+192
View File
@@ -0,0 +1,192 @@
# WireGuard Tunnel — Encrypted Node Transit
## Why
cAIc cluster traffic is plaintext today:
| Traffic | Protocol | Plaintext risk |
|---------|----------|----------------|
| AMQP (coordinator ↔ worker agent) | TCP :5672 | Registration, ping/pong, swap commands |
| Inference (coordinator → worker llama-server) | HTTP :8081 | Every token generated |
| LLM RPC layer offload (coordinator llama-server → worker) | TCP :50052 | Internal llama.cpp protocol |
WireGuard encrypts all three at the network layer with zero application changes. The cAIc app keeps using `http://` URLs — it's just talking to a virtual IP whose traffic is automatically encrypted before it hits the wire.
## Topology
```
┌───────────────────────┐ WireGuard tunnel ┌───────────────────────┐
│ Coordinator (ultron) │◄═══════════════════════════►│ Worker (jarvis) │
│ 10.0.2.1 │ UDP :51820 │ 10.0.2.2 │
│ LAN 192.168.50.108 │ │ LAN 192.168.50.210 │
│ │════════════════════════════►│ │
│ │ UDP :51820 │ Worker (corsair) │
│ │ │ 10.0.2.3 │
│ │ │ LAN (DHCP) │
└───────────────────────┘ └───────────────────────┘
```
All nodes connect directly to the coordinator's WireGuard endpoint (star topology). Workers do not need to talk to each other.
## Prerequisites
```bash
# Debian / Ubuntu
sudo apt install wireguard
# Windows / WSL2 — install WireGuard from https://www.wireguard.com/install/
# The wg.exe binary is used inside WSL2; the Windows GUI manages the tunnel config
```
## Key Generation
Run once per node. Save the private key securely; public keys go into peer configs on the other end.
```bash
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key
```
**Recorded keys for this deployment:**
| Node | Private key | Public key |
|------|-------------|------------|
| ultron | (node private) | `ultron_pubkey=` |
| jarvis | (node private) | `jarvis_pubkey=` |
| corsair | (node private) | `corsair_pubkey=` |
## Per-Node Configs
### Coordinator — `/etc/wireguard/wg0.conf` on ultron
```ini
[Interface]
Address = 10.0.2.1/24
ListenPort = 51820
PrivateKey = <ultron_private_key>
# Enable IP forwarding so workers can route through coordinator if needed
# sudo sysctl -w net.ipv4.ip_forward=1
# sudo sysctl -w net.ipv6.conf.all.forwarding=1
# Worker: jarvis
[Peer]
PublicKey = <jarvis_pubkey>
AllowedIPs = 10.0.2.2/32
# If jarvis is off-site, put its public IP / DDNS hostname here:
# Endpoint = jarvis.example.com:51820
# If jarvis is LAN-only, set PersistentKeepalive = 25 to maintain NAT binding:
# PersistentKeepalive = 25
# Worker: corsair
[Peer]
PublicKey = <corsair_pubkey>
AllowedIPs = 10.0.2.3/32
```
### Worker — `/etc/wireguard/wg0.conf` on jarvis (Linux)
```ini
[Interface]
Address = 10.0.2.2/24
ListenPort = 51820
PrivateKey = <jarvis_private_key>
# Coordinator
[Peer]
PublicKey = <ultron_pubkey>
AllowedIPs = 10.0.2.0/24
# LAN-only: just point at the LAN IP
Endpoint = 192.168.50.108:51820
# Off-site: use DDNS or static IP:
# Endpoint = ultron.example.com:51820
PersistentKeepalive = 25
```
### Worker — Windows / WSL2 on corsair
Create a WireGuard tunnel in the Windows GUI app with the same config as jarvis above (Address=10.0.2.3/24). WSL2 inside Windows can reach the tunnel IP via the Windows host.
If llama-server runs inside WSL2 on corsair, the Windows host's WireGuard tunnel IP `10.0.2.3` is reachable from the WSL2 instance as well — just bind llama-server to `0.0.0.0` (already the default) and configure the Windows firewall to allow inbound on :8081 from the coordinator's WireGuard IP.
## Starting the Tunnel
```bash
# Start immediately
sudo systemctl start wg-quick@wg0
# Enable on boot
sudo systemctl enable wg-quick@wg0
# Check status
sudo wg show
```
Expected output on each node:
```
interface: wg0
public key: <...>
private key: (hidden)
listening port: 51820
peer: <ultron_pubkey>
endpoint: 192.168.50.108:51820
allowed ips: 10.0.2.0/24
latest handshake: 5 seconds ago ← healthy
transfer: 1.2 KiB received, 3.4 KiB sent
```
If `latest handshake` is missing, check firewall rules (UDP :51820 must be open on all nodes).
## Verification
```bash
# From any node, ping another node's WireGuard IP
ping -c 3 10.0.2.1 # coordinator
ping -c 3 10.0.2.2 # jarvis
ping -c 3 10.0.2.3 # corsair
# Verify cAIc inference through the tunnel
curl http://10.0.2.2:8081/v1/models # jarvis llama-server
curl http://10.0.2.3:8081/v1/models # corsair llama-server
```
## Updating cAIc to Use the Tunnel
Once WireGuard is running, point each service at the tunnel IP instead of the LAN IP.
### Worker node agent config — `/etc/caic-node-agent.conf`
```ini
[agent]
node_name = jarvis
node_ip = 10.0.2.2 # was 192.168.50.210
node_type = worker
capabilities = llm
amqp_url = amqp://caic:password@10.0.2.1:5672/caic # was 192.168.50.108
llama_port = 8081
models_dir = /var/lib/caic/models
active_model = qwen2.5-7b-instruct-Q5_K_M.gguf
```
### Coordinator config — environment variables
```bash
# On the coordinator node, override the worker-facing addresses
# (LLAMA_SERVER_BASE stays as localhost / LAN IP since inference
# to the coordinator's own llama-server stays on-machine)
export CAIC_AMQP_URL="amqp://caic:password@10.0.2.1:5672/caic"
```
No other cAIc code changes are needed. The app already reads `CAIC_AMQP_URL` from the environment (`config.py:27`) and the node agent reads `node_ip` from its INI file. Inference requests routed to remote workers via `triage.py` use the IP the worker registered — so setting `node_ip = 10.0.2.2` in the worker's agent config is all it takes.
## Cross-Site Deployment Checklist
When placing a worker outside the LAN:
1. **Firewall:** Open UDP :51820 on the remote site. On the coordinator side, make sure UDP :51820 is reachable from the internet (port forward / firewall rule at the coordinator's router).
2. **DDNS:** If the coordinator's public IP is dynamic, set up a DDNS hostname and use it in the worker's `Endpoint = ultron.example.com:51820`.
3. **PersistentKeepalive:** Set `PersistentKeepalive = 25` on the worker side to keep NAT bindings alive.
4. **No double encryption:** WireGuard encrypts everything on the WireGuard interface. The cAIc app continues to use `http://` — it never touches raw TLS. This is correct and intended.
5. **Split tunnelling (optional):** The worker's `AllowedIPs = 10.0.2.0/24` ensures only cluster traffic goes through the tunnel. All other internet traffic from the worker uses its normal gateway.
+9 -5
View File
@@ -1,19 +1,23 @@
# cAIc Current WiP Backlog
Last updated: 2026-07-06
Last updated: 2026-07-14
Owner: Gramps
Scope: Active roadmap items and backlog.
## ~~Roadmap N: AMQP Cluster Nervous System [COMPLETE]~~
## Completed
All 15 tasks are done. Final commit: `f0689ac feat: Roadmap N — AMQP cluster nervous system complete`.
- **B7 (v0.19.0)** — Apple Silicon worker support. gpu.py darwin branch, hardware.py darwin VRAM, node_agent/agent.py macOS VRAM reporting.
- **B5 (v0.19.1)** — Default model auto-pull. model_pull.py + ensure_model() in app.py lifespan.
- **B6 (v0.19.2)** — Waterfall direction toggle (NEW/OLD), scroll/lock fixes, toast notifications, execCopy, modelLabel, Ctrl+Enter.
- **B8 (v0.19.3)** — Private Chat mode. Backend skip-DB/skip-RAG/skip-search flag, frontend PRIVATE badge, info popup.
- **WireGuard TLS (v0.19.4)** — Self-signed WireGuard mesh encrypts all inter-node traffic (AMQP, inference, RPC). No code changes to cAIc. Documented in wiki/WireGuard-Setup.md + docker.md §5.4.
## Backlog
- B1 — Context loss in follow-up questions (investigation)
- B2 — Bang-prefixed (`!`) search routing
- B3 — Docker distribution (planning doc at `docker.md`)
- **B4 — RAG Corpus Management UI (NEXT)** — browse, search, edit, delete individual RAG entries. Backend endpoints at `routers/rag_admin.py`, frontend panel in `templates/index.html`
- B3 — Docker distribution (planning doc at `docker.md`, not yet implemented)
- **B4 — RAG Corpus Management UI (deferred)** — browse, search, edit, delete individual RAG entries
- HTTPS / reverse proxy (Caddy)
- Conversation search/filter and export tooling
- Keyboard shortcuts, retry button, source-link polish
+17 -3
View File
@@ -74,6 +74,7 @@ async def chat(request: Request):
model = body.get("model", DEFAULT_MODEL)
preset_prompt = body.get("system_prompt", "")
upload_context_id = body.get("upload_context_id")
private_chat = body.get("private_chat", False)
if not user_message:
raise HTTPException(status_code=400, detail="Empty message")
@@ -81,18 +82,29 @@ async def chat(request: Request):
db = get_db()
now = datetime.now(timezone.utc).isoformat()
settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
search_enabled = settings.get("search_enabled", "true") == "true"
search_enabled = settings.get("search_enabled", "true") == "true" and not private_chat
upload_doc = None
if upload_context_id:
if upload_context_id and not private_chat:
ctx = get_upload_context(db, upload_context_id)
if ctx:
upload_doc = f"[ATTACHED DOCUMENT: {ctx['filename']}]\n{ctx['content']}\n[END DOCUMENT]"
else:
log.warning(f"upload_context_id {upload_context_id} not found or expired, continuing without it")
remember_response = process_remember_command(user_message)
remember_response = None if private_chat else process_remember_command(user_message)
if private_chat:
if not conv_id:
conv_id = str(uuid.uuid4())
system_prompt = ""
messages = []
if preset_prompt:
messages.append({"role": "system", "content": preset_prompt})
messages.append({"role": "user", "content": user_message})
history_rows = []
db.close()
else:
if not conv_id:
conv_id = str(uuid.uuid4())
title = user_message[:80] + ("..." if len(user_message) > 80 else "")
@@ -196,6 +208,7 @@ async def chat(request: Request):
yield f"data: {json.dumps({'token': cleaned_response, 'conversation_id': conv_id, 'augmented': True})}\n\n"
if not private_chat:
saved_msg = cleaned_response + "\n\n---\n*🔍 Enhanced with web search results*"
if remember_response:
saved_msg = remember_response + "\n\n" + saved_msg
@@ -217,6 +230,7 @@ async def chat(request: Request):
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH, **(rag_update and {'rag_update_suggestion': rag_update} or {})})}\n\n"
return
if not private_chat:
saved_msg = assistant_msg
if remember_response:
saved_msg = remember_response + "\n\n" + saved_msg
+4
View File
@@ -30,6 +30,10 @@ async def explicit_search(request: Request):
if not query:
raise HTTPException(status_code=400, detail="Empty query")
private_chat = body.get("private_chat", False)
if private_chat:
raise HTTPException(status_code=403, detail="Web search is disabled in private chat mode")
db = get_db()
now = datetime.now(timezone.utc).isoformat()
+36 -1
View File
@@ -101,6 +101,9 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
.badge.off { border-color:rgba(255,51,85,0.3); color:var(--danger); background:rgba(255,51,85,0.08); }
.badge.admin { border-color:var(--border); color:var(--text-muted); }
.badge.admin:hover { border-color:var(--accent-dim); color:var(--accent); }
.info-icon { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); color:var(--text-muted); font-size:11px; cursor:pointer; user-select:none; transition:all 0.15s; }
.info-icon:hover { border-color:var(--accent-dim); color:var(--accent); }
.info-popup { position:absolute; top:100%; right:0; margin-top:6px; width:320px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:10px; padding:14px 16px; font-size:12px; line-height:1.6; color:var(--text-primary); z-index:100; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.role-label .msg-time { color:var(--text-muted); font-weight:400; font-size:10px; margin-left:6px; }
.status-dot.offline { background:var(--danger); }
.status-dot.warning { background:var(--warning); }
@@ -402,6 +405,21 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
<button class="badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">SEARCH</button>
<button class="badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE</button>
<button class="badge on" id="directionBadge" onclick="toggleDirection()" title="Toggle message ordering">NEW</button>
<button class="badge off" id="privateBadge" onclick="togglePrivate()" title="Toggle private chat mode (no persistence, no external queries)">PRIVATE</button>
<span class="info-icon" id="privateInfo" onclick="togglePrivateInfo(event)" title="What is Private Chat?">&#9432;</span>
<div class="info-popup" id="privateInfoPopup" style="display:none">
<strong>&#128274; Private Chat</strong><br><br>
When enabled:<br>
&bull; Nothing is saved to the database<br>
&bull; No memory or RAG context is injected<br>
&bull; Web search is disabled (no external queries)<br>
&bull; Messages are not auto-ingested as facts<br>
&bull; No conversation history is kept after refresh<br><br>
When disabled:<br>
&bull; All conversations are persisted to disk<br>
&bull; Queries may be stored for training purposes<br>
&bull; Web search uses external services (SearXNG)
</div>
<button class="badge admin" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
</div>
</div>
@@ -452,6 +470,7 @@ let profileEnabled = true;
let searchEnabled = true;
let memoryEnabled = true;
let skillsEnabled = true;
let privateEnabled = false;
let presets = [];
let skillsRegistry = [];
let currentModel = '';
@@ -887,6 +906,20 @@ async function resetProfile() {
function toggleProfile() { if (currentRole !== 'admin') { requireAdminNotice(); return; } profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
function toggleSearch() { if (currentRole !== 'admin') { requireAdminNotice(); return; } searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
function toggleMemory() { if (currentRole !== 'admin') { requireAdminNotice(); return; } memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
function togglePrivate() {
privateEnabled = !privateEnabled;
const badge = document.getElementById('privateBadge');
badge.className = 'badge ' + (privateEnabled ? 'on' : 'off');
badge.textContent = privateEnabled ? 'PRIVATE' : 'PRIVATE OFF';
const searchBtn = document.getElementById('searchBtn');
if (privateEnabled) {
if (searchBtn) searchBtn.disabled = true;
showToast('Private chat: nothing stored, no external queries');
} else {
if (searchBtn && !isStreaming) searchBtn.disabled = false;
showToast('Public mode: conversations are persisted to disk');
}
}
function updateProfileUI() {
const badge = document.getElementById('profileBadge');
@@ -1141,6 +1174,7 @@ function showWelcome() {
}
async function sendSearch() {
if (privateEnabled) { showToast('Web search is disabled in private chat mode'); return; }
resetScrollLock();
const input = document.getElementById('userInput');
const query = input.value.trim();
@@ -1361,6 +1395,7 @@ async function sendMessage() {
}
const chatBody = { conversation_id: currentConvId, message, model };
if (uploadContextId) chatBody.upload_context_id = uploadContextId;
if (privateEnabled) chatBody.private_chat = true;
const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(chatBody), signal: abortController.signal });
const reader = resp.body.getReader();
const decoder = new TextDecoder();
@@ -1441,7 +1476,7 @@ function setStreamingState(streaming) {
sendBtn.textContent = 'SEND';
sendBtn.className = 'send-btn';
sendBtn.onclick = sendMessage;
if (searchBtn) searchBtn.disabled = false;
if (searchBtn) searchBtn.disabled = privateEnabled;
}
}
+55 -3
View File
@@ -1,5 +1,6 @@
import json
import os
import asyncio
from pathlib import Path
import httpx
@@ -261,6 +262,57 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
forget_events = parse_sse_payloads(forget_resp.text)
assert any("Forgot" in p.get("token", "") for p in forget_events)
memories_after_forget = client.get("/api/memories", headers={"X-Session-ID": sid, "Origin": "http://testserver"})
assert memories_after_forget.status_code == 200
assert memories_after_forget.json().get("count", 0) == 0
def test_private_chat_does_not_persist(tmp_path: Path, monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "stream", lambda *a, **kw: _MockStreamResponse([
'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null,"logprobs":{"content":[]}}]}',
'data: {"choices":[{"delta":{"content":" world"},"finish_reason":null,"logprobs":{"content":[]}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop","logprobs":{"content":[]}}],"usage":{"completion_tokens":2,"prompt_tokens":10,"tokens_per_second":5.0}}',
"data: [DONE]",
]))
monkeypatch.setattr(triage, "classify_query", lambda q: "general")
async def _mock_ensure(m): return True
monkeypatch.setattr("model_pull.ensure_model", _mock_ensure)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
resp = client.post("/api/chat", json={
"message": "test private message",
"private_chat": True,
}, headers=headers)
assert resp.status_code == 200
events = parse_sse_payloads(resp.text)
tokens = [p for p in events if "token" in p]
assert len(tokens) == 2
done = [p for p in events if p.get("done")]
assert len(done) == 1
conv_resp = client.get("/api/conversations", headers=headers)
assert conv_resp.status_code == 200
assert len(conv_resp.json()) == 0
def test_private_chat_does_not_auto_search(tmp_path: Path, monkeypatch):
"""Private mode should skip auto-search even when search is enabled."""
monkeypatch.setattr(httpx.AsyncClient, "stream", lambda *a, **kw: _MockStreamResponse([
'data: {"choices":[{"delta":{"content":"I don\'t know"},"finish_reason":null,"logprobs":{"content":[{"logprob":-2.5}]}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop","logprobs":{"content":[{"logprob":-2.5}]}}],"usage":{"completion_tokens":1,"prompt_tokens":10,"tokens_per_second":5.0}}',
"data: [DONE]",
]))
monkeypatch.setattr(triage, "classify_query", lambda q: "general")
monkeypatch.setattr(routers.chat, "query_searxng", lambda q: [{"title": "result"}])
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
resp = client.post("/api/chat", json={
"message": "what is the weather",
"private_chat": True,
}, headers=headers)
assert resp.status_code == 200
events = parse_sse_payloads(resp.text)
searching = [p for p in events if p.get("searching")]
assert len(searching) == 0, "private chat should not auto-search"