Run a capable AI entirely on your own laptop, no cloud, no API bills, no usage limits. Here is how to size a model to your GPU, the full build prompt for a private Qwen3 + Ollama assistant, and the mechanism behind an uncensored model, with a second prompt to add it.
Watch the full build, a private finance AI running 100% offline on a regular laptop.
Open-source models have caught up to a point where a model you run on your own machine is genuinely useful for real finance work, explaining concepts, deriving formulas, writing code, sanity-checking ideas. Running it locally buys you four things a cloud chatbot cannot:
The two prompts below build exactly that: a desktop chat app (“Miny Buffet”) that runs a local model through Ollama, with a finance-tuned system prompt, a token / context dashboard, and an optional uncensored mode.
The one number that decides what you can run is your GPU’s VRAM. A
model has to fit in memory to run fast. A 4-bit quantized model (the practical default,
labelled Q4) needs roughly 0.6 GB of VRAM per billion parameters,
plus a little headroom for the context. So an 8-billion-parameter model is about 5 GB.
| Your VRAM | Comfortable model (Q4) | Examples |
|---|---|---|
| ~4 GB | 3 to 4B | Qwen3-4B, Phi, Gemma 2B/4B |
| 6 to 8 GB | 7 to 8B ← my setup | Qwen3-8B, Llama-3.1-8B |
| 12 GB | 13 to 14B | Qwen3-14B |
| 16 to 24 GB | 24 to 32B | Qwen3-32B, Gemma-27B |
A trick worth its weight: before you commit to anything, just ask a frontier
model (Claude, ChatGPT) the budgeting question directly, something like “I have a
GPU with 6 GB of VRAM, what is the best open-source model I can run locally for finance, and
how do I run it?” It will size the model, pick the quantization, and hand you the
exact ollama pull command. You do not have to guess.
My case. I have a laptop with a 6 GB VRAM GPU (an RTX 4050). That puts me squarely in the 7-to-8B row, so I went with Qwen3-8B at Q4_K_M (about 5 GB), which fits with room left for the context window. That is the model wired into the build prompt below. On a bigger card you would simply swap the model id and keep everything else.
Paste this into Claude Code (or your coding agent of choice) and it builds the whole local
assistant: the Ollama bridge, the finance system prompt, the streaming chat UI, the
token/context dashboard, and the anti-freeze plumbing. It is written to be self-correcting,
the critical-notes section up top encodes the bugs that bit the first build so the agent
avoids them. Swap qwen3:8b for whatever your VRAM allows (see section 2).
Three knobs in the prompt decide how the model behaves day to day. Worth understanding before you run it:
num_predict) — the cap on how many tokens the model may generate in one answer. Set it too low and replies get cut off mid-sentence; too high just wastes time. The prompt wires this to a brief / normal / detailed switch.# Miny Buffet — Local AI Assistant for Finance
Build a Windows desktop chat application called **Miny Buffet** that runs an open-source LLM locally for finance-related conversations (concept explanations, derivations, code, casual chat). The user is finance-literate (CFA-level), works in English, and prefers concise, direct answers over corporate-speak.
---
## 0. CRITICAL BUILD NOTES — read before you start (these caused real bugs on the first build)
> These supersede anything later in this doc that contradicts them.
1. **Python version: use 3.11 (or 3.12), NOT the newest.** `pywebview`/`pythonnet` had no wheels for Python 3.14 at build time. If the machine has 3.14, create the venv with an explicitly older interpreter: `py -3.11 -m venv .venv`. Check `py -0p` first to see what's installed.
2. **Ollama ≥0.6 uses NATIVE thinking — the `/think` and `/no_think` message suffixes are DEAD.** qwen3 ignores them and reasons anyway. You MUST control reasoning with the `think=` parameter on `client.chat(...)`, and the reasoning comes back in `message.thinking` (a field *separate* from `message.content`). See §6 — the suffix/`<think>`-parsing approach in older drafts is replaced. This is the single biggest correction.
3. **`eval_count` includes thinking tokens.** When deep-think is on, the reported reply-token count is larger than the visible answer. That's honest/correct — display it as-is.
4. **Verify Ollama state before pulling.** On the reference machine `qwen3:8b` was already pulled and Ollama already installed — the 5 GB pull was skippable. Always check `ollama list` first.
5. **Cosmetic stderr noise is expected.** pywebview floods stderr with WebView2 COM-introspection errors (`CoreWebView2 can only be accessed from the UI thread`, `maximum recursion depth exceeded`) when `evaluate_js` is called from worker threads. They are harmless and discarded under `pythonw.exe`. Don't chase them.
6. **Don't pile up zombie processes while testing.** Each launched-then-killed app can leave `msedgewebview2.exe` children alive; after several they block new windows from initializing. Kill them between test runs (`fix_freeze.bat` does this).
---
## 1. Model & Runtime
**Model**: `qwen3:8b` (Alibaba Qwen3, 8 billion parameters, Q4_K_M quantization, ~5 GB).
**Runtime**: Ollama on `localhost:11434` (must be **Ollama ≥0.6** for native thinking). The app talks to Ollama via the official Python client (`ollama` package, ≥0.4 — 0.6.x recommended).
**Hardware target**: NVIDIA GPU with ≥6 GB VRAM (e.g., RTX 4050 Laptop). Model at Q4_K_M ≈ 5 GB on disk and in VRAM.
---
## 2. Setup (conditional — skip what's already there)
Before doing anything, check whether each prerequisite already exists:
```powershell
# 0. Which Python interpreters are installed? (pick 3.11/3.12 for the venv)
py -0p
# 1. Ollama installed?
$ollamaPath = "$env:LOCALAPPDATA\Programs\Ollama\ollama.exe"
if (-not (Test-Path $ollamaPath)) {
winget install --id Ollama.Ollama --silent --accept-source-agreements --accept-package-agreements
}
# 2. Model present? (CHECK before pulling — it may already be there)
$installed = & $ollamaPath list
if ($installed -notmatch "qwen3:8b") {
& $ollamaPath pull qwen3:8b
}
# 3. Python venv + deps — use 3.11 explicitly (NOT 3.14: no pywebview/pythonnet wheels)
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install "ollama>=0.4.0" "rich>=13.0" "pywebview>=5.0"
```
Assume **Python 3.11+** is available via `py -3.11`. Don't install Python.
> If you only have a newer Python (e.g. 3.14) and `pip install pywebview` fails building `pythonnet`, that's the wheel-availability problem — fall back to 3.11/3.12.
---
## 3. File Structure
```
miny_buffet/
├── miny_buffet.py # PyWebView host + Ollama bridge
├── ui.html # Dark chat UI with brain panel
├── fix_freeze.bat # Process cleanup + relaunch (anti-freeze)
├── requirements.txt
├── .gitignore # __pycache__/, .venv/, *.log, .env
└── vendor/ # Vendored JS libs (NO CDN — see §6)
├── marked.min.js
└── katex/
├── katex.min.css
├── katex.min.js
├── auto-render.min.js
└── fonts/ # 20× KaTeX_*-Regular.woff2 etc.
```
Download the vendor files from `cdn.jsdelivr.net/npm/katex@0.16.11/dist/...` and `cdn.jsdelivr.net/npm/marked/marked.min.js` once during setup. The app must work fully offline after first launch.
> **Fonts:** download the 20 `KaTeX_*.woff2` files only. WebView2 supports woff2, so the CSS's woff/ttf fallbacks are never requested — no need to fetch them. Set `$ProgressPreference='SilentlyContinue'` to make `Invoke-WebRequest` fast.
---
## 4. System Prompt (verbatim — use this exact template)
```python
SYSTEM_TEMPLATE = (
"You're the user's AI on their laptop. Casual friend, not corporate. "
"Finance background (CFA-level), English only, smart — don't dumb things down.\n"
"\n"
"No corporate-speak. No 'as an AI' framing. Skip preambles — answer, then stop. "
"Have opinions. Push back when the user is wrong.\n"
"\n"
"{verbosity_hint}\n"
"\n"
"Today: {today}. Training cutoff late 2024 — for anything 2025+ "
"(prices, news, earnings, events) say honestly that you don't know "
"instead of guessing.\n"
"\n"
"Math in LaTeX: $...$ inline, $$...$$ display. Never in code fences."
)
```
`{today}` is injected fresh per call from `datetime.now().strftime("%A, %B %d, %Y")`.
`{verbosity_hint}` is injected from the active verbosity preset (see §5).
---
## 5. Verbosity & Sampling Presets
### Verbosity (controls system prompt length instruction + `num_predict`)
```python
VERBOSITY = {
"brief": {"system_hint": "LENGTH: keep replies extremely short — 1-3 sentences for almost everything. For casual chat, sometimes just one line. No preamble, no closing pleasantries. Cut every word that doesn't carry information.",
"num_predict": 200},
"normal": {"system_hint": "LENGTH: be concise. Default to ~50-150 words. For casual chat, 1-3 sentences. Skip preambles. Don't pad.",
"num_predict": 800},
"detailed": {"system_hint": "LENGTH: take whatever space the question deserves. Use markdown (lists, headers, tables, bold) when structure helps. Cover edge cases.",
"num_predict": 2048},
}
```
Default: `normal`.
> **Length ownership:** `num_predict` is owned by the verbosity preset / "max length" slider. When you switch *sampling* presets (below), keep the current `num_predict` rather than overwriting it — otherwise the answer-length control fights the sampling control.
> **Deep-think + brief is a bad combo:** with thinking on, reasoning eats the `num_predict` budget before the answer starts. Don't special-case it, but know it's expected.
### Sampling (controls temperature, top_p, etc.)
```python
PRESETS = {
"precise": {"options": {"temperature": 0.4, "top_p": 0.85, "top_k": 30, "min_p": 0.05, "repeat_penalty": 1.10, "repeat_last_n": 256, "num_predict": 512}},
"balanced": {"options": {"temperature": 0.8, "top_p": 0.92, "top_k": 50, "min_p": 0.05, "repeat_penalty": 1.15, "repeat_last_n": 256, "num_predict": 1024}},
"creative": {"options": {"temperature": 1.05, "top_p": 0.97, "top_k": 100, "min_p": 0.03, "repeat_penalty": 1.25, "repeat_last_n": 512, "num_predict": 1536}},
}
```
Default: `balanced`. Generate a **random seed per call** (`random.randint(0, 2**31 - 1)`) so same questions produce different answers across runs.
---
## 6. Backend (`miny_buffet.py`)
Use **PyWebView 6.x** with explicit method exposure (DO NOT rely on `js_api` auto-discovery — it can drop methods silently when the API class has many methods). The `Api` class should expose these methods callable from JS:
| Method | Purpose |
|---|---|
| `chat(message, deep_think=False)` | Standard chat. Spawns worker thread, streams tokens to UI via `appendToken`. |
| `regenerate()` | Pop last assistant message, re-run with fresh seed (for retry button). |
| `reset()` | Wipe history + session counters. |
| `set_preset(key)` | Switch sampling preset. |
| `set_verbosity(key)` | Switch verbosity preset. |
| `set_option(key, value)` | Adjust individual sampling params (temperature, top_p, repeat_penalty, num_predict). |
| `get_system_info()` | Return live system prompt + estimated tokens for the viewer modal. |
| `attach()` | Called by JS on bridge-ready: start pre-warm + return init state. |
| `list_presets()`, `list_verbosity()`, `info()` | Metadata getters. |
In `main()`, after `webview.create_window(...)`, explicitly call `window.expose()` for each method:
```python
JS_API_METHODS = ["chat", "reset", "regenerate", "set_preset", "set_option",
"set_verbosity", "get_system_info", "info", "attach",
"list_presets", "list_verbosity"]
for name in JS_API_METHODS:
window.expose(getattr(api, name))
```
> ⚠️ **GOLDEN RULE — never call `self.window.evaluate_js(...)` synchronously inside a JS-API handler method** (the methods JS calls directly, like `chat`/`set_preset`/`attach`). pywebview runs those handlers **on the UI thread**, and `evaluate_js` needs that same thread → **hard deadlock that freezes the window**. Push to JS only from (a) a worker/daemon thread you spawned, or (b) the handler's return value, which the JS caller applies in `.then(...)`. (This rule bites harder in the uncensored add-on; see prompt2.md.)
### Load the UI so relative paths resolve
Load `ui.html` by absolute path and start with the bundled HTTP server so `vendor/...` relative URLs resolve:
```python
window = webview.create_window("Miny Buffet", os.path.join(HERE, "ui.html"),
js_api=api, width=1180, height=820,
background_color="#0d1117")
...
webview.start(debug=False, http_server=True) # http_server=True is important
```
### Pre-warm
On `attach()`, send a 1-token request in a daemon thread so the first user message isn't cold. **Pass `think=False`** so the warm-up doesn't waste time reasoning:
```python
self.client.chat(model="qwen3:8b",
messages=[{"role": "user", "content": "hi"}],
stream=False, think=False, options={"num_predict": 1})
```
### ✅ Reasoning: use Ollama's native `think=` parameter (NOT `/think` `/no_think`)
**This replaces the old suffix mechanism, which qwen3 ignores under Ollama ≥0.6.**
- `chat(message, deep_think=False)` stores `deep_think` and spawns the worker. Do **not** append `/think` or `/no_think` to the message — it does nothing and just pollutes the prompt.
- In the worker, call `self.client.chat(..., think=deep_think, stream=True, options=...)`.
- Reasoning streams back in `chunk.message.thinking`; the answer streams in `chunk.message.content`. They are **already separate** — there are no `<think>` tags to parse out of the content.
```python
def chat(self, message: str, deep_think: bool = False) -> dict:
if self._busy:
return {"status": "busy"}
self._busy = True
self._last_deep = bool(deep_think) # remembered so regenerate() reuses the mode
threading.Thread(target=self._worker, args=(message, bool(deep_think)),
daemon=True).start()
return {"status": "started"}
```
### Worker pattern
The `_worker` method appends the (plain) user message to history, builds the messages list, calls `client.chat(..., think=deep_think, stream=True, options={**self.options, "seed": seed})` with a **random seed per call** (critical for non-determinism), and streams two channels:
```python
for chunk in self.client.chat(model=self.model_id, messages=messages,
stream=True, think=deep_think, options=opts):
m = getattr(chunk, "message", None)
if m is not None:
tpiece = getattr(m, "thinking", None) or ""
cpiece = getattr(m, "content", None) or ""
if tpiece:
thinking += tpiece
self._call_js("appendThink", tpiece) # → italic reasoning block
if cpiece:
if not switched: # first answer token
switched = True
self._call_js("setStatus", "answering")
content += cpiece
self._call_js("appendToken", cpiece) # → answer bubble
if getattr(chunk, "done", False):
final_meta = {
"prompt_eval_count": getattr(chunk, "prompt_eval_count", None),
"eval_count": getattr(chunk, "eval_count", None), # incl. thinking
"load_duration_ns": getattr(chunk, "load_duration", None),
"eval_duration_ns": getattr(chunk, "eval_duration", None),
}
```
Use **attribute access** (`getattr`) on chunks, not `chunk.get(...)` — the ollama 0.6 response objects are pydantic models.
After completion, push the token breakdown to the UI: `prompt_system` (≈ len(system)//4), `prompt_current` (≈ len(user msg)//4), `prompt_history` = `prompt_eval_count − system_est − current_est` (floor at 0), plus `reply` = `eval_count`.
### History stays clean automatically
Because reasoning lives in `message.thinking` and never in `message.content`, you only ever append the answer to history:
```python
clean = THINK_RE.sub("", content).strip() # belt-and-suspenders if a future model inlines tags
self.history.append({"role": "assistant", "content": clean})
```
`THINK_RE = re.compile(r"<think>.*?</think>", flags=re.DOTALL)` is kept only as a fallback; under qwen3+Ollama-0.6 the content has no tags to strip.
### `_call_js` safety wrapper
All calls from Python into JS must be wrapped in try/except — `evaluate_js` can fail silently if the window is mid-load or backgrounded:
```python
def _call_js(self, fn: str, arg) -> None:
if self.window is None:
return
try:
self.window.evaluate_js(f"{fn}({json.dumps(arg)})")
except Exception:
pass
```
Use `self._call_js("appendToken", token)` etc. everywhere (from worker/daemon threads — see the GOLDEN RULE).
### main()
Use `webview.start(debug=False, http_server=True)`. Flip `debug=True` temporarily to enable right-click → Inspect when debugging a freeze.
---
## 7. UI (`ui.html`)
Dark theme, monospace finance aesthetic. Two columns: chat (flex), brain panel (300px, collapsible).
### Header (left → right)
- **Brand**: `Miny Buffet` + status pill `● qwen3:8b · local`
- **Toggles**: `deep think` (drives the `think=` parameter), `raw view` (shows the model's raw streaming output incl. the reasoning channel in the brain panel)
- **Buttons**: `↻ retry` (disabled until first AI reply), `brain` (toggles panel), `new chat`
### Footer (input row)
```
[textarea: Message Miny Buffet… (Enter to send, Shift+Enter for newline)] [send]
```
### Brain Panel (top → bottom)
1. **Status line** with colored pulsing dot: `idle / warming / ready / thinking / answering / done / error`
2. **ACTIONS** section: `↻ retry last answer`, `🗑 reset context (new chat)`
3. **SESSION TOTALS**: prompt in, reply out, total tokens, gen time, wall time, avg speed, turns
4. **CONTEXT WINDOW**: progress bar `used / max (2048)`, color-coded (green <70%, amber 70-90%, red ≥90%) with hint text
5. **LAST TURN** with **prompt subdivided** (prompt total → system / history / current msg with %), reply, time, speed, mode (`fast`/`deep`)
6. **TIMING**: load, eval (from Ollama metadata)
7. **MODEL**: active (`qwen3:8b`), quant (Q4_K_M), params (8.19B), runtime (ollama · local)
8. **SYSTEM PROMPT**: a `view current prompt` button with a token badge; click opens a modal (closes on X, backdrop, Escape)
9. **ANSWER LENGTH**: 3-button segmented control `brief | normal | detailed` (syncs `num_predict` slider AND system prompt instruction)
10. **SAMPLING**: preset row `precise | balanced | creative`; sliders `temperature`, `top_p`, `repeat_penalty`, `max length`; display `style` (preset or "custom") and `seed`
11. **RAW STREAM** (toggled via header): raw output. Reconstruct it as `<think>…</think>` + answer from the two channels (thinking is highlighted).
### Chat rendering
- User messages: blue bubble, right-aligned, `white-space: pre-wrap`
- AI messages: dark bubble, left-aligned
- **Markdown** with `marked.js` (live during streaming)
- **LaTeX** with `KaTeX` auto-render **after streaming completes** (delimiters `$…$`, `$$…$$`, `\(…\)`, `\[…\]`; `throwOnError:false`). Don't run KaTeX mid-stream — a half-arrived `$…$` breaks it. KaTeX's default ignore-list already skips `code`/`pre`, which satisfies "never in code fences".
- **Thinking blocks**: fed by the `appendThink` channel — render as a separate italic block (border-left accent) above the answer. Never stored in history.
### Token counting fix
During streaming, the LIVE token count must be a **character-based estimate** of *both* channels: `Math.floor((thinkChars + contentChars) / 4)`, NOT chunk count (Ollama batches multiple tokens per chunk). At completion, snap the displayed reply count to the authoritative `eval_count`.
### Bridge readiness gate
Input/send start **disabled**; enable only when `window.pywebview.api.chat` is confirmed a function (poll on `pywebviewready`, ~5 s timeout fallback). Also handle the case where `pywebview` already exists before the listener attaches.
---
## 8. Anti-Freeze Measures (critical)
### A) JS bridge readiness gate (in `ui.html`)
Start disabled; enable on confirmed bridge (see §7).
### B) Stale process cleanup script (`fix_freeze.bat`)
```bat
@echo off
echo Cleaning up stale processes...
taskkill /F /IM pythonw.exe >nul 2>&1
taskkill /F /IM msedgewebview2.exe >nul 2>&1
timeout /t 2 /nobreak >nul
echo Launching Miny Buffet...
start "" "%~dp0.venv\Scripts\pythonw.exe" "%~dp0miny_buffet.py"
```
WebView2 child processes don't always exit cleanly; after a few launches, zombies accumulate and block new instances. **This bit us repeatedly during automated testing** — clean between runs.
### C) Suppress pywebview noise
Wrap all `evaluate_js` in try/except. Use `pythonw.exe` in the shortcut so stderr (the COM-introspection spam) is discarded. The spam is cosmetic; ignore it.
### D) Vendor libraries locally
No CDN at runtime — WebView2 has flaky CDN handling. Vendor everything (~600 KB).
---
## 9. Desktop Shortcut
Create a desktop shortcut pointing at `pythonw.exe` (NOT `python.exe`) so no console window appears:
```powershell
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut("$env:USERPROFILE\Desktop\Miny Buffet.lnk")
$Shortcut.TargetPath = "<projectDir>\.venv\Scripts\pythonw.exe"
$Shortcut.Arguments = "`"<projectDir>\miny_buffet.py`""
$Shortcut.WorkingDirectory = "<projectDir>"
$Shortcut.Description = "Miny Buffet - local finance AI"
$Shortcut.Save()
```
---
## 10. Verification
Prefer to verify the backend WITHOUT the GUI first (fast, no zombies), then do ONE clean GUI smoke test:
- **Backend smoke test** (no webview): instantiate `Api` with a fake `window` whose `evaluate_js` records calls; run a fast chat, a deep-think chat (assert `message.thinking` streamed and is NOT in history), `regenerate`, and the setters. This catches the ollama-API field-name issues without launching a window.
- **GUI smoke test**: drive the real window from a daemon thread via `evaluate_js` (check `typeof marked/katex/renderMathInElement`, the bridge, a chat round-trip into the DOM, KaTeX rendering). **Always give it a watchdog thread that force-destroys + `os._exit` after ~25 s**, so a bug can never hang the window or leave zombies. Clean up `msedgewebview2.exe`/test python between runs.
Functional checklist: boot <5 s no console; bridge gate enables input; first chat streams + markdown + KaTeX; brain panel populates (note `reply` = `eval_count` includes thinking when deep); verbosity brief/detailed differ; creative vs precise differ on retry; reset wipes; `fix_freeze.bat` relaunches clean.
---
## Non-Goals
- No web UI / browser version. PyWebView desktop only.
- No telemetry, analytics, or logging beyond Ollama's defaults.
- No accounts, no persistence across sessions (each launch starts fresh).
- No multi-user. Single-user, single-machine. No cloud fallback. 100% local.
Build it.
A safety-trained model refuses certain prompts. The striking finding behind abliteration, the technique popularised by Maxime Labonne (2024), is that this refusal behaviour is controlled by a single “refusal direction” in the model’s internal activations. Identify that direction and project it out of the weights and you get a model with the same knowledge and capabilities, but no built-in refusals. Nothing is added, one direction is surgically removed. Labonne’s writeup and notebook are open-source on GitHub (building on FailSpy’s abliterator and the original Arditi et al. “refusal is a single direction” result).
The community publishes pre-abliterated versions of popular models on HuggingFace and Ollama,
so you do not have to do the surgery yourself. The add-on prompt uses
huihui_ai/qwen3-abliterated:8b, the same Qwen3-8B you are already running with
that one direction removed, and adds a header toggle to switch between the safety-trained and
uncensored models. On a 6 GB GPU only one is resident at a time, Ollama swaps them in and out
of VRAM as you toggle (about 5 seconds the first time).
# Miny Buffet — Uncensored Mode (add-on)
This is an extension to **Miny Buffet** (see `prompt1.md` for the base build). It adds a second model — an **abliterated** Qwen3 variant with the refusal direction removed from the weights — and a header toggle to switch between safety-trained and uncensored modes.
**Apply only after the base build from `prompt1.md` is complete and verified.**
---
## 0. CRITICAL BUILD NOTES — read before you start (these caused real bugs on the first build)
> These supersede anything later in this doc that contradicts them.
1. **THE BIG ONE — never call `evaluate_js` synchronously inside a JS-API handler.** `set_model()` is called directly by JS, so it runs on pywebview's **UI thread**. If it calls `self._call_js(...)` / `self.window.evaluate_js(...)` inline, it deadlocks the UI thread (evaluate_js needs that thread) and **the whole window freezes hard** on the first toggle. The fix: `set_model` must only mutate state + spawn the warm-up thread + **return** the new model info; the JS caller applies it in `.then(...)`. Status pushes happen from the prewarm **thread**, never from the handler body. (Same golden rule as prompt1.md §6 — it bites hardest here.)
2. **Don't assume the two models have different `num_ctx`.** On the reference machine *neither* Modelfile sets a `num_ctx` parameter (`ollama show` → both report arch `context_length` 40960 and fall back to Ollama's default). There was **no** 2048-vs-4096 split to display. Verify with `ollama show` before hardcoding per-model context sizes; if they're the same, keep one soft budget (2048) for both rather than inventing a difference. The per-model field should still exist so the gauge stays correct if a future model genuinely differs.
3. **The abliterated model may already be pulled.** Check `ollama list` first — on the reference machine `huihui_ai/qwen3-abliterated:8b` was already present, so the 5 GB pull was skippable.
4. **History persists across a model swap (by design).** `set_model` must NOT clear `self.history` — switching mid-conversation keeps context.
---
## What's an "abliterated" model?
Abliteration (Maxime Labonne, 2024) identifies and projects out the "refusal direction" from a model's residual stream. The result: same knowledge and capabilities, but no built-in refusals on edgy/taboo/politically-incorrect topics. The community publishes pre-abliterated versions on HuggingFace and Ollama.
For Miny Buffet, we use `huihui_ai/qwen3-abliterated:8b` — the same Qwen3-8B you're already running, with one direction surgically removed.
---
## 1. Pull the abliterated model (check first!)
```powershell
$ollamaPath = "$env:LOCALAPPDATA\Programs\Ollama\ollama.exe"
$installed = & $ollamaPath list
if ($installed -notmatch "huihui_ai/qwen3-abliterated:8b") {
& $ollamaPath pull huihui_ai/qwen3-abliterated:8b
}
```
~5 GB if not already present. Lives alongside `qwen3:8b`. Ollama swaps them in/out of VRAM as you toggle (only one resident at a time on a 6 GB GPU).
---
## 2. Backend changes (`miny_buffet.py`)
### Add a MODELS dict + state
```python
MODELS = {
"default": {
"id": "qwen3:8b",
"label": "qwen3 · default",
"note": "safety-trained",
"num_ctx": 2048, # see §0 note 2 — verify with `ollama show`
},
"uncensored": {
"id": "huihui_ai/qwen3-abliterated:8b",
"label": "qwen3 · uncensored",
"note": "abliterated · refusal direction removed",
"num_ctx": 2048, # same as default on the reference machine
},
}
```
In `Api.__init__`:
```python
self.model_key = "uncensored" # default to abliterated on launch
self.context_max = MODELS[self.model_key]["num_ctx"] # gauge max (per-model)
self._warmed = set() # model keys already pre-warmed
self._warming = set() # model keys with a warm-up in flight
```
Add a `model_id` property:
```python
@property
def model_id(self) -> str:
return MODELS[self.model_key]["id"]
```
Then in `_worker`, use `self.model_id` instead of the hardcoded model name. In `_build_metrics` and `reset()`, use `self.context_max` instead of the `CONTEXT_MAX` constant (keep the constant as a fallback).
### Add the model API methods — ⚠️ NO synchronous `evaluate_js` here
```python
def list_models(self) -> list:
return [{"key": k, **v} for k, v in MODELS.items()]
def get_model(self) -> dict:
return {"key": self.model_key, **MODELS[self.model_key]}
def set_model(self, key: str) -> dict:
if key not in MODELS:
return {"status": "error", "note": f"unknown model: {key}"}
if self._busy:
return {"status": "busy"}
self.model_key = key
m = MODELS[key]
self.context_max = m["num_ctx"]
warm = key in self._warmed
# ⚠️ DO NOT call evaluate_js here — this runs on the UI thread and would
# deadlock. The JS caller updates the UI from this return value; the prewarm
# thread (below) pushes status changes from its own thread.
self._start_warm(key)
return {"status": "ok", "model": m["id"], "key": key,
"label": m["label"], "note": m["note"],
"num_ctx": m["num_ctx"], "warm": warm}
```
`_start_warm` only spawns a thread (or no-ops) — it must also never call `evaluate_js` directly, since it's reached from UI-thread handlers (`set_model`, `attach`):
```python
def _start_warm(self, key: str) -> None:
if key in self._warmed or key in self._warming:
return
self._warming.add(key)
threading.Thread(target=self._prewarm, args=(key,), daemon=True).start()
```
`_prewarm` takes a model key, runs on its own thread, and is the ONLY place that pushes status during warm-up (safe — not the UI thread):
```python
def _prewarm(self, key: str) -> None:
m = MODELS[key]
if key == self.model_key:
self._call_js("setStatus", "warming")
try:
self.client.chat(model=m["id"],
messages=[{"role": "user", "content": "hi"}],
stream=False, think=False, options={"num_predict": 1})
self._warmed.add(key)
if key == self.model_key:
self._call_js("setStatus", "ready")
except Exception as e:
if key == self.model_key:
self._call_js("setStatus", "error")
self._call_js("streamError", f"prewarm failed: {e}")
finally:
self._warming.discard(key)
```
In `attach()`, warm the **current** model (`self._start_warm(self.model_key)`), not always `"default"`, and include the model fields in the returned init payload so JS can render the right pill/colors on boot.
### Update info()
Return the live model identity + the model list so the UI initializes correctly:
```python
m = MODELS[self.model_key]
return {
"model": m["id"], "model_key": self.model_key,
"model_label": m["label"], "model_note": m["note"],
"models": [{"key": k, **v} for k, v in MODELS.items()],
"context_max": self.context_max,
# ... plus the existing preset/verbosity/options/seed fields ...
}
```
### Update JS_API_METHODS
Add the three strings `"set_model"`, `"get_model"`, `"list_models"` to the existing list (don't replace it wholesale) before the `window.expose()` loop.
### Update SYSTEM_TEMPLATE (optional, applies to both models)
The abliterated version doesn't need polite framing, but the same prompt works for both. Tightened version:
```python
SYSTEM_TEMPLATE = (
"You're the user's AI on their laptop. Casual friend, not corporate. "
"Finance background (CFA-level), English only, smart — don't dumb things down.\n"
"\n"
"No 'as an AI', no disclaimers, no moralizing. Engage edgy/taboo stuff "
"honestly. Have opinions. Push back. Swear if it fits the vibe. "
"Skip preambles — answer, then stop.\n"
"\n"
"{verbosity_hint}\n"
"\n"
"Today: {today}. Training cutoff late 2024 — for anything 2025+ "
"(prices, news, earnings, events) say honestly that you don't know "
"instead of guessing.\n"
"\n"
"Math in LaTeX: $...$ inline, $$...$$ display. Never in code fences."
)
```
Abliteration removes the refusal direction; the system prompt still steers tone on both models.
---
## 3. UI changes (`ui.html`)
### Header — add uncensored toggle (before `deep think`)
Match the existing toggle style (a `.tgl` button), defaulting ON, with a warning/amber on-state so "uncensored is active" reads at a glance:
```html
<button class="tgl warn on" id="tglUncensored"
title="Use the abliterated Qwen3 — refusal direction removed from the weights.">uncensored</button>
```
```css
.tgl.warn.on{background:rgba(210,153,34,.18);border-color:var(--amber);color:var(--amber)}
```
### Header status pill — make it dynamic
The pill dot now reflects the **active model** (amber = uncensored, green = default), not status. The brain panel's own status dot remains the status indicator.
```html
<span class="pill"><span class="dot" id="pillDot" style="background:var(--amber)"></span><span id="pillLabel">qwen3 · uncensored</span></span>
```
> When you make the pill dot model-driven, **remove any code in `setStatus` that overwrites `pillDot`'s color** — otherwise status and model fight over the same dot.
### Brain panel — MODEL section (dynamic)
```html
<div class="row"><span class="k">active</span>
<span class="v" id="bModelLabel" style="color:var(--amber)">qwen3 · uncensored</span></div>
<div class="row"><span class="k">variant</span>
<span class="v" id="bModelNote" style="color:var(--muted)">abliterated · refusal direction removed</span></div>
<!-- quant / params / runtime stay static — both models share the architecture -->
```
### JS — a single `setModel(m)` applier + a reusable context renderer
Factor the context bar into a function so a model swap can update the gauge **max** without waiting for a turn:
```js
let ctxUsedCur = 0, ctxMaxCur = 2048;
function renderContext(used, max){
ctxUsedCur = used; ctxMaxCur = max || ctxMaxCur || 2048;
const pc = Math.min(100, ctxMaxCur>0 ? ctxUsedCur/ctxMaxCur*100 : 0);
/* set width, ctxUsed/ctxMax/ctxPct text, green/amber/red color + hint */
}
window.setModel = function(m){ // callable from JS and from Python push
if(!m) return;
const unc = m.key ? m.key==="uncensored" : /uncensored/.test(m.label||"");
const col = unc ? "var(--amber)" : "var(--green)";
if(m.label){ pillLabel.textContent=m.label; bModelLabel.textContent=m.label;
bModelLabel.style.color=col; }
pillDot.style.background = col;
if(m.note) bModelNote.textContent = m.note;
tglUncensored.classList.toggle("on", unc);
if(m.num_ctx) renderContext(ctxUsedCur, m.num_ctx);
};
```
### JS — wire the toggle (apply from the RETURN value, not a Python push)
```js
tglUncensored.addEventListener('click', () => {
if (busy){ setStatus('answering', 'finish current message first'); return; }
const target = tglUncensored.classList.contains('on') ? 'default' : 'uncensored';
window.pywebview.api.set_model(target).then(res => {
if (!res) return;
if (res.status === 'busy'){ setStatus('answering', 'finish current message first'); return; }
if (res.status === 'ok'){
setModel({key:res.key, label:res.label, note:res.note, num_ctx:res.num_ctx});
setStatus(res.warm ? 'ready' : 'warming');
}
});
});
```
> Because `set_model` returns the new identity, the UI updates from the promise — no Python→JS push inside the handler (which would deadlock). The prewarm thread still pushes `warming → ready` safely from its own thread.
### JS — bootstrap applies the model on boot
In the `attach().then(d => {...})` handler, call `setModel({key:d.model_key, label:d.model_label, note:d.model_note, num_ctx:d.context_max})`.
---
## 4. Verification
Verify the **backend without the GUI first** (no zombies, fast):
- `api.model_key == "uncensored"`, `api.model_id == "huihui_ai/qwen3-abliterated:8b"`, `context_max` correct.
- `list_models()` returns 2; `get_model()`/`info()` show the live model.
- `set_model("default")` flips `model_id` to `qwen3:8b`; `set_model("nope")` → error; busy-guard returns `busy`.
- Run a short generation on **both** models (assert both stream tokens).
Then ONE **clean, watchdog-protected** GUI test (force-destroy + `os._exit` after ~25 s, clean `msedgewebview2`/test-python between runs):
1. **Default on launch**: header `qwen3 · uncensored`, amber dot, toggle on, brain MODEL shows abliterated note.
2. **Switch to default**: pill → green `qwen3 · default`, toggle off, brain note → "safety-trained", status → `warming`, backend `model_key=="default"`. **The window must NOT freeze** (this is the deadlock regression check).
3. **Switch back**: uncensored restored.
4. **Switch mid-conversation**: history persists.
5. **No two models in VRAM at once**: `ollama ps` shows one model loaded; swapping takes ~5 s the first time.
> Behavioral check (item: an edgy prompt the safety-trained model refuses) depends on model **weights**, not your code — exercise it manually; you can't meaningfully assert it in an automated test.
---
## 5. Trade-off note
The abliterated model uses ~5 GB VRAM, same as the base. Switching takes ~5 s the first time (Ollama loads from disk, evicting the other). With a 6 GB GPU, only one is resident at a time (`keep_alive` 5 min default). Don't run both simultaneously.
---
## Appendix: what changed vs. the original add-on draft
The original draft assumed (a) a `self._brain({...})` push helper called *synchronously inside `set_model`* and (b) a real per-model `num_ctx` difference (2048 vs 4096). Both were wrong against the actual runtime:
- (a) caused a **UI-thread deadlock** → window froze on first toggle. Fixed by returning the model info for the JS `.then()` to apply and pushing status only from the prewarm thread. This is the most important change in this file.
- (b) the installed Modelfiles set **no** `num_ctx`; both report arch ctx 40960. Don't fabricate a difference — verify with `ollama show` and keep both at the 2048 soft budget unless they genuinely differ.
One honest limitation: this model’s training data ends in late 2024. Ask it about anything newer, a 2025 earnings print, this week’s news, today’s price, and it simply does not know. A well-behaved local model should say so rather than invent an answer, but the real fix is to research it and hand the model the fresh information.
This third prompt adds exactly that: a manual search button. You click it, the app pulls live results (Google News RSS for current events, DuckDuckGo as a fallback, no API keys), injects them into the prompt as context, and the model answers from them and cites the sources. It is deliberately manual, not automatic: a small 8B model’s tool-calling is unreliable, so you decide when it searches and always know what context it has. The results are one-shot context, they are not saved into the conversation history.
# Miny Buffet — Web Search (add-on)
This is an extension to **Miny Buffet** (see `prompt.md` for the base build). It adds a manual web-search button that lets the model answer questions using fresh search results as context. Useful because the model's training data ends in late 2024 — anything more recent (prices, news, earnings) it doesn't know.
**Apply only after the base build from `prompt.md` is complete and verified.** Works alongside `prompt2.md` (uncensored mode) if that's also installed.
---
## Why manual (not auto)
The model itself never decides to search. The user clicks a `🔍 search` button to opt in. Reasons:
- Qwen3-8B's tool-calling reliability isn't great (~80%) — would search at weird times
- Costs no extra tokens when search isn't needed
- User always knows what context the model has
- Simpler to implement than full tool-calling
If you want auto-search later, that's a separate (larger) change.
---
## 1. Add dependencies
```powershell
.\.venv\Scripts\python.exe -m pip install "feedparser>=6.0" "requests>=2.31"
```
Update `requirements.txt`:
```
ollama>=0.4.0
rich>=13.0
pywebview>=5.0
feedparser>=6.0
requests>=2.31
```
---
## 2. Create `search.py`
Two sources, no API keys:
1. **Google News RSS** — primary, for current events / market news
2. **DuckDuckGo HTML** — fallback when no news results
```python
"""Web search backend — Google News RSS + DuckDuckGo HTML."""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from datetime import datetime
from urllib.parse import quote_plus, unquote, parse_qs, urlparse
import feedparser
import requests
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
TIMEOUT = 10
_CACHE: dict[tuple[str, str], tuple[float, list]] = {}
_CACHE_TTL = 300 # 5 min
@dataclass
class Result:
title: str
link: str
source: str
date: str
snippet: str = ""
def to_dict(self) -> dict:
return {"title": self.title, "link": self.link,
"source": self.source, "date": self.date, "snippet": self.snippet}
def _cache_get(kind, query):
hit = _CACHE.get((kind, query.lower()))
if not hit: return None
ts, results = hit
if time.time() - ts > _CACHE_TTL:
_CACHE.pop((kind, query.lower()), None)
return None
return results
def _cache_set(kind, query, results):
_CACHE[(kind, query.lower())] = (time.time(), results)
def search_news(query: str, n: int = 6) -> list[Result]:
"""Top n news headlines for a query from Google News RSS."""
cached = _cache_get("news", query)
if cached is not None: return cached[:n]
url = f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=en-US&gl=US&ceid=US:en"
feed = feedparser.parse(url)
results = []
for entry in feed.entries[:n]:
title = re.sub(r"<[^>]+>", "", entry.get("title", "")).strip()
src = entry.get("source", {})
source = src.get("title", "") if isinstance(src, dict) else str(src)
results.append(Result(title=title, link=entry.get("link", ""),
source=source or "unknown", date=entry.get("published", "")))
_cache_set("news", query, results)
return results
def search_web(query: str, n: int = 6) -> list[Result]:
"""General web search via DuckDuckGo HTML — used as fallback."""
cached = _cache_get("web", query)
if cached is not None: return cached[:n]
try:
resp = requests.get(f"https://duckduckgo.com/html/?q={quote_plus(query)}",
headers={"User-Agent": UA}, timeout=TIMEOUT, allow_redirects=True)
resp.raise_for_status()
except Exception:
return []
results = []
block_re = re.compile(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
r'.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
re.DOTALL)
for m in block_re.finditer(resp.text):
href = m.group(1)
title = re.sub(r"<[^>]+>", "", m.group(2)).strip()
snippet = re.sub(r"<[^>]+>", "", m.group(3)).strip()
if "uddg=" in href:
try:
href = unquote(parse_qs(urlparse(href).query).get("uddg", [href])[0])
except Exception: pass
domain = urlparse(href).netloc.replace("www.", "") if href else ""
results.append(Result(title=title, link=href, source=domain, date="", snippet=snippet))
if len(results) >= n: break
_cache_set("web", query, results)
return results
def format_for_model(query: str, results: list[Result], kind: str) -> str:
"""Format search results as a tool-result message the model can read."""
if not results:
return f"[Web search for '{query}' returned no results.]"
now = datetime.now().strftime("%Y-%m-%d %H:%M")
lines = [f"[{kind.title()} search results for: {query}]",
f"[Fetched at {now} local time]\n"]
for i, r in enumerate(results, 1):
lines.append(f"{i}. {r.title}")
if r.snippet: lines.append(f" {r.snippet}")
meta = [r.source, r.date]
meta = [m for m in meta if m]
if meta: lines.append(f" ({' · '.join(meta)})")
if r.link: lines.append(f" {r.link}")
lines.append("")
return "\n".join(lines)
```
---
## 3. Backend changes (`miny_buffet.py`)
### Import
```python
from search import search_news, search_web, format_for_model
```
### Add the API method
```python
def search_and_chat(self, message: str, deep_think: bool = False) -> dict:
"""Search the web, inject results as context, then answer the question."""
if self._busy:
return {"status": "busy"}
self._busy = True
threading.Thread(target=self._search_worker, args=(message, deep_think), daemon=True).start()
return {"status": "started"}
```
### Add the worker
```python
def _search_worker(self, original_message: str, deep_think: bool) -> None:
try:
self._brain({"status": "searching", "note": f"searching the web: {original_message[:60]}…"})
results = search_news(original_message, n=5)
kind = "news"
if not results:
results = search_web(original_message, n=5)
kind = "web"
self._call_js("searchResultsBubble", {
"query": original_message, "kind": kind,
"results": [r.to_dict() for r in results],
})
if not results:
self._brain({"status": "error", "note": "no search results"})
self._call_js("errorMessage", "No search results — try a different query.")
self._busy = False
return
context_text = format_for_model(original_message, results, kind)
suffix = "/think" if deep_think else "/no_think"
original_tagged = f"{original_message} {suffix}"
augmented = (
"Use these web search results to answer the question. "
"Cite sources by their number when relevant. "
"If the results don't fully answer, say what's missing.\n\n"
f"{context_text}\n\n"
f"Question: {original_message} {suffix}"
)
prepared_messages = [
{"role": "system", "content": get_system_prompt(self.verbosity_key)},
*self.history,
{"role": "user", "content": augmented},
]
# _worker stores `original_tagged` in history (clean) but sends `augmented` to the model
self._worker(original_tagged, deep_think, model_messages=prepared_messages)
except Exception as e:
self._brain({"status": "error", "note": f"search failed: {e}"})
self._call_js("errorMessage", f"Search failed: {e}")
self._busy = False
```
### Modify `_worker` to accept optional `model_messages` override
```python
def _worker(self, tagged_message: str, deep_think: bool,
model_messages: list[dict] | None = None) -> None:
self.history.append({"role": "user", "content": tagged_message})
if model_messages is None:
messages = [
{"role": "system", "content": get_system_prompt(self.verbosity_key)},
*self.history,
]
else:
messages = model_messages
# ... rest of _worker unchanged
```
This way the user's original question is what gets stored in history (clean), while the model sees the augmented version with search results inline (one-shot context, not persisted).
### Update JS_API_METHODS
Add the string `"search_and_chat"` to the existing `JS_API_METHODS` list. Don't replace the list wholesale — if `prompt2.md` was also applied, the list already contains additional methods (`set_model`, `list_models`, `get_model`), and you want all of them. The order doesn't matter; just ensure `search_and_chat` is included before the explicit `window.expose()` loop runs.
### Update SYSTEM_TEMPLATE
Tweak the knowledge-cutoff section to mention the search button:
```python
"Today: {today}. Training cutoff late 2024 — for anything 2025+ "
"(prices, news, earnings, events) say \"I don't know, click 🔍 search\" "
"instead of guessing."
```
This way the model knows the search button exists and will suggest it.
---
## 4. UI changes (`ui.html`)
### Footer — add the search button
```html
<footer>
<textarea id="input" placeholder="Message Miny Buffet… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
<button id="searchBtn" onclick="searchMessage()" title="Search the web first, then answer using the results">🔍 search</button>
<button id="send" onclick="sendMessage()">send</button>
</footer>
```
### CSS — style the search button and the results bubble
```css
#searchBtn {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
padding: 0 12px;
height: 42px;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
letter-spacing: 0.3px;
font-family: inherit;
}
#searchBtn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent-soft);
}
#searchBtn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Search results bubble — sits between user message and AI answer */
.msg.search {
align-self: flex-start;
background: #0f1620;
border: 1px solid #1f3344;
border-left: 3px solid var(--accent);
font-size: 12.5px;
color: var(--text-dim);
max-width: 88%;
padding: 10px 14px;
border-radius: 6px;
line-height: 1.5;
}
.msg.search .head {
color: var(--accent);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.6px;
font-weight: 600;
margin-bottom: 6px;
}
.msg.search .query {
color: var(--text);
font-style: italic;
font-weight: normal;
text-transform: none;
letter-spacing: 0;
}
.msg.search ol { margin: 0; padding-left: 22px; }
.msg.search li { margin-bottom: 5px; }
.msg.search a { color: #8ab8e8; text-decoration: none; }
.msg.search a:hover { text-decoration: underline; }
.msg.search .meta {
color: var(--text-faint);
font-size: 11px;
margin-left: 4px;
}
.msg.search.empty { color: var(--text-faint); font-style: italic; }
```
### JS — handlers
```js
const searchBtn = document.getElementById('searchBtn');
// Include searchBtn in the disable/enable helpers
function disableInput() {
sendBtn.disabled = true; input.disabled = true;
if (searchBtn) searchBtn.disabled = true;
}
function enableInput() {
sendBtn.disabled = false; input.disabled = false;
if (searchBtn) searchBtn.disabled = false;
input.focus();
}
function addSearchBubble(payload) {
hidePlaceholder();
const div = document.createElement('div');
div.className = 'msg search';
if (!payload.results || payload.results.length === 0) {
div.classList.add('empty');
div.innerHTML = `<div class="head">🔍 web search<span class="query"> — "${escapeHtml(payload.query)}"</span></div>(no results)`;
messages.appendChild(div);
scrollToBottom();
return;
}
const items = payload.results.map((r) => {
const link = r.link
? `<a href="${escapeHtml(r.link)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`
: escapeHtml(r.title);
const meta = [r.source, r.date].filter(Boolean).join(' · ');
return `<li>${link}${meta ? `<span class="meta"> — ${escapeHtml(meta)}</span>` : ''}</li>`;
}).join('');
div.innerHTML =
`<div class="head">🔍 web search<span class="query"> — "${escapeHtml(payload.query)}"</span> · ${payload.kind || 'web'}</div>` +
`<ol>${items}</ol>`;
messages.appendChild(div);
scrollToBottom();
}
// Called from Python when search results come back.
function searchResultsBubble(payload) {
addSearchBubble(payload);
}
async function searchMessage() {
const text = input.value.trim();
if (!text) return;
if (!window.pywebview) return;
const deepThink = thinkToggle.checked;
addUserMessage(text);
input.value = '';
input.style.height = 'auto';
disableInput();
cumulativeRaw = "";
updateRawStream();
try {
await window.pywebview.api.search_and_chat(text, deepThink);
} catch (e) {
errorMessage(String(e));
}
}
```
The AI answer bubble is created lazily by `appendToken()` when the first token arrives — so the visual order in the chat becomes:
```
[user message]
[🔍 search results bubble]
[AI answer]
```
### Bridge readiness check — extend
In the `pywebviewready` handler, also confirm `search_and_chat` is exposed before enabling input (in case the new method failed to bind):
```js
if (window.pywebview && window.pywebview.api &&
typeof window.pywebview.api.chat === 'function' &&
typeof window.pywebview.api.search_and_chat === 'function') {
clearInterval(check);
enableInput();
refreshSystemPromptInfo();
}
```
---
## 5. Verification
1. **Button renders**: footer shows `[textarea] [🔍 search] [send]`. Search button is disabled at launch until bridge ready, then enables.
2. **News query**: type "latest news on NVDA", click `🔍 search`. Within ~3 sec, a dark-blue bordered bubble appears with 5 headlines + sources + clickable links. Model then summarizes.
3. **Fallback**: type a non-news query like "what is the Riemann hypothesis", click `🔍 search`. If Google News returns nothing, DuckDuckGo fallback fires; bubble shows web results instead.
4. **Cite-by-number**: model should reference results like "according to [2]…" or "the MarketWatch article (3) says…".
5. **History stays clean**: after a search-and-answer turn, click `view current prompt` in the brain panel — the conversation history shown should be just your original question, not the augmented version with all the search results inline. (Search context is one-shot, not persisted.)
6. **Cache**: search the same query twice within 5 min — second time should return ~instantly (no network).
7. **Date awareness**: when the model uses search results, it should mention current dates correctly (from the result metadata and the injected `{today}` in the system prompt).
8. **No internet**: disconnect Wi-Fi, click search. Bubble shows "(no results)" or an error; chat continues normally without crashing.
---
## Limitations
- **Headlines + source only.** The model sees title + source + URL, not full article body. Good for "what's happening" but not "deep dive on this article." Adding article-body fetch is doable later (more code, fragile, sites block bots).
- **No live prices or quotes.** Google News doesn't surface real-time financial data. For live AAPL/NVDA prices you'd want a separate Yahoo Finance integration via the `yfinance` Python library.
- **Manual only.** The model doesn't decide when to search. The user always clicks the button.
- **English bias.** Google News RSS works in any locale but the default URL targets US English (`hl=en-US&gl=US`). For Portuguese / Spanish results you'd need to parametrize the locale.
Watch the full build on YouTube. If you build your own, tell me what model you ran and on what card.