A modern, feature-rich document reader with neural text-to-speech powered by Kokoro TTS, an optional local-AI chat mode powered by Ollama, and (new in v1.6.0) document-aware chat with RAG + autonomous tool calling backed by Postgres + pgvector. Open PDFs, .txt, or .md files, have them read aloud with natural-sounding voices, ask the model about what you're reading, or let the model search the indexed doc on its own.
🎯 A web frontend for Kokoro TTS — now with chat that knows what you're reading. Beyond the TTS reader and the standalone Ollama chat side-mode, the app can index a loaded document into pgvector and expose a
search_documentstool that your local LLM calls autonomously when a question warrants it. Everything stays local: Ollama for the LLM + embeddings, Postgres in a container for chat sessions and vectors, no cloud round-trips. A browser-based Web Speech fallback is also available for testing without any backend.
🔌 Works 100% Offline! Once installed, the app runs completely without internet. PDF.js is bundled locally, and Kokoro TTS / Ollama run on your machine.
- Drag & Drop Upload — Drop PDFs,
.txt, or.mdfiles directly onto the window - PDF Rendering — Smooth page-by-page rendering with zoom controls
- Plain Text (.txt) Support — Text files are paginated into pseudo-pages (~40 sentences) and use the same reading pipeline as PDFs (sentence highlight, library, resume progress, selection-read)
- Markdown (.md) Support — Markdown files render with proper formatting (headings, lists, code blocks, tables, blockquotes) via
react-markdown+remark-gfm. Pagination is paragraph-aware (~40 sentence soft cap, breaks only on block boundaries). The block currently being read is highlighted at the paragraph level, and TTS strips Markdown markup so**bold**reads as "bold" and code blocks are skipped entirely. - Table of Contents — Navigate using the PDF's chapter outline (if available)
- Text Selection — Select text directly on the rendered page for copying or reading
- Zoom Controls — Zoom in/out, fit to page, fit to width (font size for
.txtand.md) - Page Jump — Click the page indicator and type any page number
- Kokoro TTS Integration — High-quality neural text-to-speech via local backend
- 27 Voice Options — Wide selection of US and UK male/female voices with live preview
- Speed Control — Adjust playback speed from 0.5× to 2×
- Volume Control — Adjustable audio volume slider
- Audio Buffering — Pre-fetches upcoming sentences for seamless playback
- Auto Page Advance — Automatically continues reading across pages
- Download Page Audio — Export the current page as a WAV file
- Selective Read — Select any text and read only that selection
- Continue From Here — Right-click any sentence to start reading from that point
- Browser Fallback — Uses Web Speech API when backend is unavailable
- Auto-Failover — Automatically switches to browser voice if backend is unreachable
- Reader ↔ Chat Toggle — Switch the main view between document reader and chat mode from the header
- Streaming Replies — Every chat turn runs on the server (
server/chat/) and streams token-by-token overPOST /v1/chat/sessions/{id}/turns(server-sent events); the browser never talks to a model provider directly - Model Picker — Grouped by provider (native Ollama, plus any OpenAI-compatible server an admin has configured — vLLM, OpenRouter, LiteLLM), with capability badges per model. Adding or changing providers is an operator
.envtask (INFERENCE_PROVIDERS, see .env.example); there's no in-app provider screen yet - Image Attachments — Paperclip button, drag & drop onto the chat view, and
Ctrl + Vpaste images from the clipboard. Thumbnails preview above the prompt and persist in user bubbles. Sent to vision-capable models via the per-messageimages: [base64]field. (Audio attachments are temporarily paused — see CHANGELOG.) - Model Response Stats — Every assistant bubble has a collapsible footer showing token count, total time, and tokens/sec. Expanded view breaks out load / prompt-eval / generation phases for fine-grained latency inspection.
- Stick-to-Bottom Scroll — The chat list auto-tails streaming tokens when you're at the bottom; scrolling up pauses the auto-follow so you can read history during a long response, and resumes when you scroll back down.
- Chat TTS — Replies are read aloud through the same Kokoro pipeline, with bounded prefetch to avoid gaps
- Streaming vs After-Complete TTS — Read sentences as they stream in, or wait for the full reply before reading
- Per-Message Read Aloud — A
🔊 Read aloud/■ Stopbutton on every assistant bubble — works even with auto-TTS off, or to re-read finished messages later - Markdown Rendering — Lists, code blocks, tables, headings, links render natively in chat bubbles
- Markdown-Aware TTS — Markup is stripped before synthesis so audio reads visible text only (no "star star bold")
- Per-Model Inference Settings — An
Inferenceblock in the chat sidebar sets the context window (num_ctx), keep-alive, thinking level, and max reply tokens for the selected model, saved per model so a 9.7B and a 3B can differ on the same machine. Every control defaults to Auto, which omits the key entirely and leaves Ollama's own memory-based sizing alone — nothing changes until you opt in. Note that changing the context window makes Ollama reload the model. - Reasoning Trace Control — The
Thinkingsetting sendsthinkto Ollama and surfaces the reasoning trace (deepseek-r1, qwen3-thinking, gpt-oss, …) in a collapsible disclosure that auto-expands while streaming. Five states:Off/On(booleans) plus the graduatedLow/Medium/Highlevels — useful on slow hardware, where reasoning tokens are the most expensive output a model can produce. Models that accept the boolean but reject a level are retried automatically. - Context Meter & Truncation Warning — A
~3.3k / 16k ctxreadout above the composer estimates how full the window is (amber past 75%), and if a reply is cut off because the context filled up, a toast names the actual numbers instead of leavingdone_reason: lengthburied in the stats disclosure. - Per-Message Copy — One-click copy of any chat message to clipboard
- Chrome "Read Aloud" extension — A Manifest V3 browser extension (in
extension/) that reads the selected text or the whole page aloud on any site through this project's local Kokoro TTS backend. No build step — loadextension/unpacked. Floating Shadow-DOM toolbar (play/pause, stop, seek, voice, speed) via right-click or a popup; whole-page reads chunk into sentences and stream with prefetch. Loopback-only permissions, backend unchanged. Setup + known site limits in extension/README.md. - Markdown folder workspace — Open a folder so a Markdown/text file's cross-file links navigate in-reader: relative
.md/.txtlinks open the target,#headinganchors scroll, relative images render, with Back/Forward history. Uses the File System Access API (with a folder-picker fallback), persists across reloads, and rejects dangerous link schemes.
- Convert PDF → Markdown with Docling — Optional backend pipeline (gated by
DOCLING_ENABLED=true) that turns a PDF into layout-aware Markdown using Docling. Click Convert in the PDF toolbar to pick a quality preset (Fast / Standard / Accurate — the Accurate path uses GraniteDocling VLM), force-enable OCR for scanned PDFs, toggle table extraction, choose image handling (drop / embed-base64 / VLM-describe), or limit a page range. After conversion the doc is auto re-chunked + re-embedded so RAG picks up tables and headings the native pdf.js extractor missed. - PDF ↔ MD reader toggle — Once converted, a small
PDF | MDsegmented control in the toolbar swaps the canvas for a wide Markdown reader with anchored per-page separators. The Download / Trash icons next to it export the converted MD ({filename}.md) or wipe it server-side. - Audiobook export — Library icon in the header synthesises every page through
/v1/batch_synthesize, stitches the WAVs client-side, and downloads{filename}_audiobook.wav. Progress + cancel UI; up to 3 pages in flight at once. SetWORKERS=N python run.pyto fan synthesis across CPU cores (one Kokoro model per worker; see docs/CHAT_WITH_PDF.md §10). - Per-message chat audio export — Every assistant chat message has a new Audio button that downloads it as
.wav(filename derived from session title + message index). - Home button — Header icon (reader mode, doc open) returns to the library; reading progress was already persisted so reopening resumes where you left off.
- Distraction-free mode — Press
F(or click theMaximize2button in the header) to hide Header, sidebar, mobile bottom nav, and the PDF toolbar. Small floating Exit pill in the top-right brings everything back. Persisted across reloads; works in both reader and chat. - Mobile overflow menu — On phones, secondary header actions (dark mode, TTS backend, page audio, audiobook, shortcuts, home, distraction-free) collapse into a
⋯dropdown so the top bar stops feeling cramped.
A full end-to-end walkthrough lives in docs/CHAT_WITH_PDF.md. Headline capabilities:
- Ask page — One toolbar click pins the current page text (~8000 char cap) to the chat. No indexing required.
- Ask AI on a selection — Highlight any text on the rendered page and pin just that snippet. Paired with the existing "Read Selection" TTS button.
- Pinned context — Ask page / Ask AI create pins: excerpts that stay attached to the conversation and are re-sent to the model on every turn — positioned at the very top of the prompt so they never get buried — until you remove them. Multiple pins accumulate as removable chips, dedupe by content, are bounded (6 pins / ~12 000 chars), and are saved with the chat session (restored on reload). Whole-document breadth comes from autonomous retrieval (below), not a giant pin.
- Index this document — Backed by Postgres + pgvector. The file is uploaded, and the server extracts per-page (PDF), per-block (Markdown) or per-pseudo-page (TXT) chunks on the reader's own pagination, embeds them via Ollama's
nomic-embed-text(768-dim) and stores them in an HNSW-indexedvectorcolumn. A file that's already indexed on the server is indexed for you at once. - Autonomous tool calling — When a doc is indexed and the chat model reports tool support, the model gets a
search_documentstool it can invoke on its own, plus tools to read pages and go through many of them for one goal; when SearXNG is configured it can also offer a web search (see Web search below). The turn runs server-side (server/chat/): the server executes the call, hands the result back, and the model streams the final answer. Up to 3 tool rounds by default (CHAT_MAX_TOOL_ROUNDS; 5 in a chat about a whole project,CHAT_PROJECT_MAX_TOOL_ROUNDS), so the model can follow a trail through the document, then one last step with tools switched off, so the turn always ends in an answer. Tool results share a per-answer budget (CHAT_TOOL_RESULT_BUDGET_CHARS), and web search refuses queries that copy the document; a model without tool support just never sees the tool. Tool calls are persisted in atool_callsJSONB column and re-rendered as a 🔎 disclosure on the assistant bubble. - Postgres-backed chat sessions — Sessions previously stored in IndexedDB now write to Postgres via a new
src/lib/sessionStore.jsabstraction. Legacy IDB sessions stay readable with a small LOCAL badge; the first message you send on one copies it onto the server (POST /v1/chat/sessions/import), leaving the original intact. - Server-side tool registry —
server/chat/tools/houses one tool per file (search_documents,read_document_pages,extract_from_documents,document_outline,web_search,read_web_pages,offer_web_search). Adding a tool later is one new file + one registry line; there is no browser-side tool code anymore.
Natural Reader is now a multi-user, authenticated application. End-user walkthrough: docs/USER_GUIDE.md; operator setup: docs/DEPLOYMENT.md and docs/IDENTITY_AND_ROLES.md.
- Sign in (OIDC / Keycloak) — Every API route requires an authenticated principal. First login becomes admin; others wait for activation. Personal access tokens (
nrp_…) let the Chrome extension and scripts authenticate outside the browser, each limited to what it's for (read aloud, documents, chat, admin) and optionally expiring. SetAUTH_ENABLED=falsefor a loopback-only single-user box. - Capabilities (reader / chat / admin) — Feature access is governed by Keycloak realm roles enforced on the server and reflected in the UI: you only see the views you're entitled to, and an activated user with no capabilities gets a clear "access not yet granted" screen.
- Admin console — A dedicated Admin view (shield icon in the top switcher, admins only): enroll users (invite or one-time temp password), edit capabilities, disable/delete — propagated to Keycloak, with a last-active-admin guard so you can't lock the org out.
- Model providers & daily budgets — Chat always runs on the server, never in the browser. An admin configures one or more model providers in
.env(native Ollama by default, plus any OpenAI-compatible server — see .env.example); there's a deployment model allowlist per provider and an optional per-user daily token budget (a "N tokens left today" meter; send disables at zero; a reply you stop or that fails still counts, from the provider's own count or an estimate). - Document Library — Documents are private by default. The server verifies every upload, and a file someone already indexed is added to your library instantly. In the app you can create a project (Library → New project), add people to it with a role (Reader, Contributor, Maintainer, Owner; Members → + Add people), file your uploads into it, keep every version of a document (upload a new version, restore or merge; the Library shows only the current one), and share a single document with one person (Share in the Library). A Library view lists, searches and filters your documents by project and tags, and shows what was shared with you and by whom. Removing a document only removes your copy. Non-readers get a 404, never a hint that the document exists.
- Consolidated Settings + profile menu — Voice, chat/inference (per-model), connection, appearance, and account (tokens) settings live in one Settings window that opens over whatever you have open, so changing a setting never stops the narration or a reply. It's reached from a header gear and a profile menu (identity, Settings, dark mode, log out). The reader and chat sidebars are trimmed to navigation and model/sessions. The chat composer draft survives switching tabs.
- Ask a whole project — Ask about this project on a project page (or Choose a project… on any chat's scope chip) starts a chat about all of the project's current documents. The assistant gets a map of the project (names, pages, headings), searches every document as one ranked list, and cites each fact as (Guide.md, page 7); the link opens that document at that page, and the source chips are grouped by document. Old versions are never searched.
- A reader for "every step, in order" — For questions that need many pages, a separate model call (the reader) reads them for one goal and returns only facts whose quotes the server finds on the page. Its calls count toward the daily token budget.
READER_MODEL=chatreuses the chat model, so a CPU-only box loads one model. - Web search asks first — By default the assistant can only offer a search: a Search the web? card shows its reason and the query in an editable box, and nothing leaves the server until you press Search the web. Facts from the web cite their site. Admins pick Ask first, Search without asking or Off under Admin → Chat options;
CHAT_WEB_SEARCHsets the starting value. In a project chat the card doesn't always appear; typing "search the web for …" brings it up. - Code blocks, outlines and table cells are searchable — A Markdown code block is its own passage, so an exact command can be found and quoted.
- Narration everywhere, chat beside the page — The narration keeps playing in Chat, Library and Admin, with a small header control. On a wide screen the chat opens in a column next to the page.
- Profile — A profile picture, a Manage your account link to the sign-in provider, and your signed-in browsers with Sign out everywhere else. Admins set defaults for everyone (voice, speed, auto-read, thinking) under Admin → Defaults.
Walkthroughs: docs/USER_GUIDE.md; operator knobs: docs/DEPLOYMENT.md.
- Dark Mode — Beautiful dark/light theme toggle with smooth transitions
- Sentence Highlighting — Visual highlighting of the current sentence during playback
- Auto-Scroll — Sidebar automatically scrolls to the current sentence
- Reading Progress — Visual progress bar showing page completion percentage
- Estimated Time — Shows remaining reading time for the current page
- Responsive Layout — Full mobile support with dedicated bottom navigation
- Collapsible Sidebar Sections — Settings, Sessions, Conversation, and Session log are independently collapsible with their own scroll containers
- Toast Notifications — Informative feedback for user actions
- Document Library (IndexedDB) — PDFs,
.txt, and.mdfiles saved locally for instant resume (up to 5 docs) - Chat Sessions (IndexedDB) — Every chat is auto-saved; switch / rename / delete from the sidebar; auto-named from the first prompt; LRU-capped at 50 sessions
- Per-Session Event Log —
sent,received,aborted,errorevents with timestamps, viewable as a collapsible log - One-Click Resume — Click any document in the library to continue reading
- Settings Saved — Voice, speed, volume, zoom, theme, Ollama host/port, model, TTS mode all persist across sessions
- Reading Progress — Remembers your position in each document (page + sentence)
| Key | Action | Mode |
|---|---|---|
Space |
Play / Pause | Reader only |
Escape |
Stop playback | Reader only |
Shift + ← |
Previous sentence | Reader only |
Shift + → |
Next sentence | Reader only |
Page Up |
Previous page | Reader only |
Page Down |
Next page | Reader only |
Ctrl + + |
Zoom in | Both |
Ctrl + - |
Zoom out | Both |
Ctrl + D |
Toggle dark mode | Both |
F |
Distraction-free mode | Both |
Enter |
Send message | Chat (in prompt box) |
Shift + Enter |
New line in prompt | Chat (in prompt box) |
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite (Rolldown) |
| PDF Parsing | PDF.js 5.x (bundled locally) |
| Markdown | react-markdown + remark-gfm (chat replies) |
| Styling | Tailwind CSS 4.x |
| Icons | Lucide React |
| Storage | localStorage + IndexedDB (books + chat_sessions stores) |
| TTS Backend | FastAPI, Kokoro ONNX, Uvicorn |
| TTS Inference | ONNX Runtime (CPU by default; CUDA / OpenVINO builds opt-in on bare metal) |
| Chat Backend (optional) | Ollama — local LLM server (default :11434) |
| Doc Chat Backend (optional, new in 1.6) | FastAPI routers: /v1/chat/sessions/* and /v1/docs/* |
| Doc Storage / RAG | Postgres 16 + pgvector (HNSW, cosine), embedded via Ollama nomic-embed-text |
| DB driver | psycopg[binary,pool] (async) with pgvector codec registered per-connection |
| PDF → Markdown (optional, new in 1.7) | Docling — layout-aware extraction with optional GraniteDocling VLM. Gated by DOCLING_ENABLED=true. |
git clone https://github.com/udaravima/natural-reader.git && cd natural-reader
./startup.sh quickstart # or: ./startup.sh quickstart --single-userIt checks for a container engine (podman with podman-compose 1.1+, or Docker with the Compose plugin 2.24+) and Node 20.19+/22.12+. No host Python is needed: the backend runs in a container. It writes a local-dev .env (never touching an existing one), builds the backend image, starts Postgres, Keycloak, SearXNG and the backend, then the frontend dev server. Open http://localhost:5173 and sign in as admin-user / password. --single-user turns sign-in off and skips Keycloak. Re-running it after a failure picks up where it stopped.
| Command | What it does |
|---|---|
./startup.sh start |
live mode: the whole stack, detached, the backend running the code baked into its image |
./startup.sh stop |
stop and remove the containers (data stays in volumes and data/) |
./startup.sh rebuild |
rebuild the backend image after code changes; recreate only the backend |
./startup.sh logs |
follow the backend's output |
./startup.sh enable-autostart |
podman: come back after a reboot (enables lingering and podman-restart.service) |
./startup.sh up / up-with-dev-auth |
dev: the backend container runs your working tree; Ctrl-C stops everything |
BACKEND_RUNTIME=venv ./startup.sh up |
the bare-metal backend, as before the container (needs ./startup.sh init and Python 3.12–3.13). Put BACKEND_RUNTIME=venv in .env to make it the default for up / up-with-dev-auth |
Supported: Linux on x86_64. Not supported in the container: ARM machines, including Apple Silicon Macs (untested there), and macOS in general is untested (its stock bash 3.2 can't run startup.sh). Windows and macOS use the bare-metal flow (startup.ps1 / startup.cmd on Windows, Python 3.12–3.13) described below.
Install this before you start. The app is split into a React frontend (Vite) and a Python TTS backend (FastAPI). Chat and RAG layer on optional extras.
| Software | Version | Why |
|---|---|---|
| Node.js + npm | 20.19+ or 22.12+ |
Frontend dev server / build. Vite 7 (Rolldown) and @vitejs/plugin-react declare engines: ^20.19.0 || >=22.12.0 — older Node will fail to start. npm ships with Node. |
| Python | 3.12–3.13 |
The backend (run.py). The chat server needs 3.12+ (inspect.getasyncgenstate); 3.14 is untested. |
| Git | any recent | Clone the repository |
| wget or curl | any | Download the Kokoro voice model files (~335 MB total — see step 2) |
💡 Tip: use
nvmto pin Node and a.venvto isolate Python — the install steps below assume both.
| Software | Version | Why |
|---|---|---|
| Ollama | latest | Runs the local LLM for chat mode (and embeddings for RAG). Reader + TTS work without it. |
| Software | Version | Why |
|---|---|---|
| Docker + Docker Compose, or Podman + podman-compose | recent | Runs Postgres in a container via docker-compose.yml. startup.sh supports either engine. |
| — or — host PostgreSQL + pgvector | pg16 |
Stores chat sessions and document embeddings. The provided container image is pgvector/pgvector:pg16. |
Ollama embedding model nomic-embed-text |
768-dim | Indexing / retrieval / autonomous search_documents tool calling. The schema is hard-locked to 768 dims. |
📦 Disk: budget ~335 MB for the Kokoro model + voice pack, and (only if you enable
DOCLING_ENABLED=true) an extra ~500 MB–2 GB downloaded on first conversion for the Docling layout/table models.
Bare-metal setup (Windows, macOS, or
BACKEND_RUNTIME=venv). On Linux, the Quickstart above is the shorter path.
git clone https://github.com/udaravima/natural-reader.git
cd natural-reader
# Frontend
npm install
# Backend
python3 -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
pip install -r requirements-docling.txt # optional: only for DOCLING_ENABLED=true# Kokoro v1.0 ONNX model (~310 MB)
wget https://github.com/nazdridoy/kokoro-tts/releases/download/v1.0.0/kokoro-v1.0.onnx
# Voice pack (~25 MB)
wget https://github.com/nazdridoy/kokoro-tts/releases/download/v1.0.0/voices-v1.0.binPlace both files in the project root directory.
Auth note: since the multi-user OIDC work, the backend enables authentication by default (
AUTH_ENABLED=true, see.env.example). The twostartup.shmodes below pick the posture for you:upruns with the single-user dev bypass (no login screen),up-with-dev-authruns the full local OIDC rig against a Keycloak container.
# Terminal 1 — quick single-user dev (Postgres + SearXNG + the backend container
# running your working tree, auth off; BACKEND_RUNTIME=venv for the bare-metal one)
./startup.sh up
# Or, the full local OIDC rig (also starts Keycloak on :18080, creates .env on
# first run with the local realm values + a generated SESSION_SECRET, waits for
# the realm import, then runs the backend with auth on):
./startup.sh up-with-dev-auth
# → sign in at http://localhost:5173 as Keycloak user admin-user / password
# Walkthrough (adding a second user, approval flow, PATs): deploy/README.md
# End-user guide (signing in, personal access tokens, admin section,
# chat budgets): docs/USER_GUIDE.md
# Or, to run the backend manually (no containers, no chat persistence):
python run.py
# To fan TTS / audiobook synthesis across CPU cores (one Kokoro model
# loaded per worker — budget ~300–500 MB each on the ONNX-CPU build):
# WORKERS=4 python run.py
# HOST and PORT env vars are also honoured.
# Terminal 2 — Start the frontend dev server (port 5173)
npm run devup-with-dev-auth sources .env (created from the local-dev rig values on
first run — see deploy/README.md); edit it to change ports or point at a
different IdP. up also reads .env but forces AUTH_ENABLED=false (the
backend's startup guard allows this only on a loopback bind). Ctrl-C on either
stops the backend and the containers cleanly; ./startup.sh stop (or down)
does the same without starting anything.
startup.cmd (or startup.ps1 in PowerShell) has the same commands as
startup.sh. It runs on the Windows PowerShell 5.1 built into Windows 10/11,
and on PowerShell 7.
startup.cmd init :: or: startup.cmd init podman
startup.cmd up :: quick single-user dev, auth off
startup.cmd up-with-dev-auth
startup.cmd downYou need Docker Desktop (or Podman Desktop), Python 3.12 or 3.13 from
python.org with Add to PATH ticked, and Node.js. startup.cmd runs the
script with -ExecutionPolicy Bypass for that one run, so the machine's
execution policy can stay as it is. From a PowerShell prompt you can also run
.\startup.ps1 up directly if your policy allows local scripts.
These parts work differently from Linux:
- Python: the script uses the
pylauncher when it can, becausepython.exeon a fresh Windows is often the Microsoft Store stub. - The virtual environment lives in
.venv\Scripts\. - Stopping: Ctrl-C reaches the backend directly, and it shuts down
cleanly.
downfrom another window has no gentler option on Windows: after 5 seconds it ends the backend and every worker process it started (taskkill /T).
Open http://localhost:5173 in your browser.
The chat mode talks to a locally running Ollama server. Reader mode works without it — chat is purely opt-in.
# Install Ollama (https://ollama.com/download), then pull a chat model
ollama pull gemma3
# Reasoning model that produces a thinking trace
ollama pull deepseek-r1:1.5bOllama serves at http://localhost:11434 by default. In the app, toggle to Chat in the header — host/port and model picker live in the chat sidebar.
To use Ask page, pinned context, Index this document, and autonomous tool calling (see the walkthrough for the full tour), you need Postgres + an embedding model.
# Bring up Postgres + pgvector (port 5433 on the host to avoid colliding with a system Postgres on 5432)
docker-compose up -d postgres
# Pull the embedding model (768-dim — the schema is hard-locked to this)
ollama pull nomic-embed-text
# Optional: pull a chat model that supports Ollama's tools parameter (for autonomous search_documents)
ollama pull qwen2.5 # or llama3.1 / llama3.2 / mistral / gemma2python run.py applies migrations on startup and exposes the new endpoints under /v1/chat/sessions/* and /v1/docs/* — the existing Kokoro routes are unchanged. Chat itself needs Postgres now (every turn is written to it as it streams): if Postgres is unreachable, sending a chat message returns 503 db_unavailable instead of a reply. TTS (/v1/synthesize, /v1/batch_synthesize) doesn't touch Postgres and keeps working regardless.
The chat can use the live web via a self-hosted SearXNG instance. A search returns numbered results (title, site, address, snippet) and fetches nothing. By default the assistant only offers a search: a card under its reply shows the query in an editable box, and nothing leaves the server until you press Search the web. After a yes it can read 1-3 of the results: a small separate model call (the reader, llama3.2:3b by default) reads each page for the assistant's goal and returns only facts with quotes the server has checked against the page, and the answer cites the sites. Admins choose how it asks under Admin → Chat options → Web search (Ask first, Search without asking, Off); CHAT_WEB_SEARCH=ask|auto|off is the starting value.
./startup.sh updoes thesettings.ymlbootstrap below automatically (copies the template and injects asecret_key) and brings SearXNG up alongside Postgres. The manual steps here are for running SearXNG on its own.
# Create the SearXNG config from the template and set a real secret_key
cp searxng/settings.yml.example searxng/settings.yml
sed -i "s/CHANGE_ME_openssl_rand_hex_32/$(openssl rand -hex 32)/" searxng/settings.yml
# Start SearXNG on 127.0.0.1:18043 (settings.yml is gitignored — it holds the secret_key)
docker-compose up -d searxng
# Pull the reader's model (skip it with READER_MODEL=chat, which reads with the chat model)
ollama pull llama3.2:3b
# Verify JSON search works — must return JSON, not a 403/HTML page
curl -s "http://localhost:18043/search?q=test&format=json" | head -c 80A 403 means SearXNG's JSON format is disabled — check that searxng/settings.yml lists json under search.formats.
All knobs are optional; see the WEB_SEARCH_* and SEARXNG_URL entries in .env.example. WEB_SEARCH_RESULT_COUNT (default 5) is how many results one search lists; pages are fetched only when the assistant reads them. The reader's knobs are the READER_* entries; see docs/DEPLOYMENT.md.
Security note: the backend only fetches URLs whose host resolves to a public IP — private/loopback/link-local addresses (including cloud metadata endpoints) are refused, and every redirect hop is re-checked.
The TTS backend uses the best execution provider that the installed ONNX
Runtime build offers. requirements.txt installs the CPU build, and so
does the container image. To accelerate a bare-metal install, swap the build
in your venv. Remove onnxruntime first: kokoro-onnx pulls it in, and two
builds in one venv overwrite each other, which leaves you silently on CPU.
| Priority | Hardware | Package | Notes |
|---|---|---|---|
| 1 | NVIDIA GPU | onnxruntime-gpu |
CUDA acceleration |
| 2 | Intel Arc GPU | onnxruntime-openvino openvino |
OpenVINO discrete GPU |
| 3 | Intel NPU | onnxruntime-openvino openvino |
Core Ultra Neural Processing Unit |
| 4 | Intel CPU | onnxruntime-openvino openvino |
AVX/VNNI optimizations |
| 5 | CPU | onnxruntime |
Standard fallback |
# Intel Arc GPU / NPU / CPU via OpenVINO
pip uninstall -y onnxruntime && pip install onnxruntime-openvino openvino
# NVIDIA GPU
pip uninstall -y onnxruntime && pip install onnxruntime-gpuCheck the backend's startup line Available ONNX Runtime providers: [...]:
it must list OpenVINOExecutionProvider or CUDAExecutionProvider, or you are
on CPU. The container is CPU-only (see docs/DEPLOYMENT.md).
- Upload a PDF via the upload button or drag-and-drop
- Toggle to "SYSTEM" mode in the header
- Press Play — the browser's built-in Web Speech API reads the text
- Start the backend:
python run.py - Ensure the header shows "KOKORO" (green indicator)
- Upload a PDF and press Play — enjoy neural-quality voices!
The frontend talks to two backends. Each has its own host/port (configurable in the sidebar). Leave the host field blank to hit the same origin the page was served from — useful when an nginx (or similar) reverse proxy is fronting both services on a single domain.
| Endpoint | Method | Description |
|---|---|---|
/v1/health |
GET |
Health check — verifies the model is loaded. Cheap; safe to call during playback. |
/v1/synthesize |
POST |
Synthesize one block of text → Base64 WAV audio. Used for per-sentence reader playback, selection-read, voice preview, and chat TTS. |
/v1/batch_synthesize |
POST |
Synthesize multiple sentences → single merged WAV with 0.3 s silence between. Used by "Download Page Audio". |
All endpoints return 503 when Postgres is unreachable.
| Endpoint | Method | Description |
|---|---|---|
/v1/chat/sessions |
GET |
List session metadata, newest first. |
/v1/chat/sessions/{id} |
GET / PATCH / DELETE |
Per-session read / rename / delete. The server writes messages itself as a turn streams; there's no client-side upsert of the whole record anymore. |
/v1/chat/sessions/import |
POST |
Create-only: copies a legacy browser-only (IndexedDB) chat onto the server the first time you send a message on it. |
/v1/chat/sessions/{id}/turns |
POST |
Run one chat turn. Server-sent events (text/event-stream): text/reasoning deltas, tool calls and their results, a final finish, or a terminal error; always ends with data: [DONE]. One turn per session at a time — a second send gets 409 turn_in_progress. |
/v1/docs |
POST / GET |
POST: upload a file (multipart). The server hashes the bytes itself (that SHA-256 is the doc_id) and extracts + indexes new content in the background; known bytes just add it to your library. GET: the documents you can read (?q=, ?project_id=, ?tag=). |
/v1/docs/{doc_id} |
GET / PATCH / DELETE |
Status (state, chunk_count, embedded_count, model, dim); rename/retag your entry; remove it from your library (the content goes when nobody holds it). |
/v1/docs/{doc_id}/file |
GET |
The stored file, for anyone who can read the document (404 otherwise). The Library's Open button. |
/v1/docs/{doc_id}/index |
POST |
Resume, or re-index from the stored file; returns 202. Poll the doc status endpoint for progress. |
/v1/docs/{doc_id}/search |
POST |
{query, k} → the top-k chunks by cosine similarity, an exact search within the document. It is meaning-only: the chat's search_documents tool adds exact-word matching. Answers 409 reindexing while the document is rebuilt for a new embedding model. |
Sharing, projects, conversion and who may call what: docs/LIBRARY.md § API surface. There is no chunk-upload route: chunks are always derived on the server.
Chat always runs on the server (server/chat/, server/llm/) against one or more configured model providers — native Ollama by default, plus any OpenAI-compatible server (vLLM, OpenRouter, LiteLLM) an admin adds in .env. OpenRouter has been run end to end (paid Gemma 4 and Mistral Small 3.2: chat, pins, tools, images, usage); vLLM and LiteLLM speak the same protocol but haven't been run. A refused request says what happened (rate-limited, key rejected, out of credit) — see docs/DEPLOYMENT.md. The browser never talks to a provider directly; there is no "local mode" and no in-app screen for adding providers (.env + restart, see .env.example and docs/DEPLOYMENT.md).
| Endpoint | Method | Description |
|---|---|---|
/v1/inference/models |
GET |
Every provider's models as {id, provider, kind, name, capabilities} (id is <provider>:<model>), plus the caller's daily budget {remaining_tokens, reset_at}. |
Admin knobs: INFERENCE_PROVIDERS + INFERENCE_<NAME>_* (providers), INFERENCE_MODELS (legacy single-Ollama allowlist, still the default with no providers configured), INFERENCE_DAILY_TOKEN_BUDGET (default per-user daily tokens, UTC-midnight reset — a reply you stop or that fails still counts), per-user overrides + usage view via /v1/admin/users/{id} and /v1/admin/inference/usage. See .env.example.
Ollama is the no-config default chat provider and always runs document/chat embeddings (OLLAMA_URL), even after other providers are added — removing it breaks indexing, not just chat:
| Endpoint | Method | Description |
|---|---|---|
/api/tags |
GET |
Lists installed models. The backend calls this server-side; the browser never does. |
/api/chat |
POST |
Streaming chat, called by the backend's Ollama adapter (server/llm/providers/ollama.py). Per-message images: [...] carries vision attachments (audio attachments are still disabled in the UI). |
Request / Response Examples
POST /v1/synthesize
{
"text": "Text to synthesize",
"voice": "af_heart",
"speed": 1.0
}Response:
{
"audio_base64": "<base64-encoded-wav>",
"duration_seconds": 2.5
}POST /v1/batch_synthesize
{
"sentences": ["First sentence.", "Second sentence."],
"voice": "af_heart",
"speed": 1.0
}Response:
{
"audio_base64": "<base64-encoded-wav>",
"duration_seconds": 5.2,
"sentence_count": 2
}For production, the typical setup is to serve the frontend as static files from a web server (nginx, Caddy, …) and reverse-proxy the backend on the same hostname. The frontend supports this natively: leaving the Host field blank in the sidebar settings causes requests to be issued as same-origin paths (/v1/synthesize, /v1/chat/sessions/{id}/turns, …). nginx (or whatever sits in front) handles the routing. Chat always goes through the backend; no /api/ proxy to Ollama is needed — see docs/DEPLOYMENT.md § Model providers and chat streaming for the streaming-through-a-proxy trap.
A complete, battle-tested config (Ed25519 + RSA fallback, gzip, the works) lives at docs/sample_configs/chat.example.com.sample. The minimal version below is what's actually load-bearing:
Running multi-user (OIDC/Keycloak)? This section covers the SPA + backend vhost only. For the full production picture — the second vhost that fronts Keycloak (
docs/sample_configs/auth.example.com.sample), the same-origin cookie rule,COOKIE_SECURE, the Keycloak proxy-header env, and the live-realm redirect-URI trap — see docs/DEPLOYMENT.md.
server {
listen 80;
server_name chat.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name chat.example.com;
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# Static frontend (output of `npm run build`)
root /var/www/chat.example.com/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Backend (Kokoro TTS + doc RAG + server-side chat, server/chat/ + server/llm/)
location /v1/ {
proxy_pass http://127.0.0.1:8000/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streaming (chat turns are text/event-stream) — disable response
# buffering so tokens arrive live; long-lived synthesis needs the
# generous timeout. The backend also sends X-Accel-Buffering: no.
proxy_buffering off;
proxy_read_timeout 86400;
# Document uploads (POST /v1/docs) exceed nginx's 1 MB default → 413.
client_max_body_size 100m;
}
# Ollama: NO location block. Chat always runs server-side against the
# provider(s) configured in .env (native Ollama by default); the browser
# never calls Ollama directly, and there is no "local mode" to proxy for.
# Ollama stays backend-only (loopback bind).
}In the reader sidebar (Voice API), clear the Host field. The placeholder will read "localhost (blank = same origin)" and a small hint will appear confirming the mode. The Port field is then ignored. Chat has no host/port field at all — it always talks to the backend, and the backend is the only thing that ever calls Ollama.
Notes:
- Ollama bind address. By default Ollama listens on
127.0.0.1:11434. That's fine here since only the backend talks to it. If you changeOLLAMA_HOSTto bind on a different interface, mirror it in the backend's provider config (OLLAMA_URL/INFERENCE_<NAME>_URL, see .env.example). - Streaming.
proxy_buffering offon/v1/is required so chat turns stream token-by-token instead of arriving as one buffered chunk. - CORS. Same-origin requests don't need CORS at all. The Kokoro server's permissive CORS header (set in server/app.py) is harmless but unused under this setup.
- Custom hostnames during development. If you want to test the reader's Voice API against a non-localhost machine without proxying, set Host to an IP / hostname (e.g.
192.168.1.10) and the matching Port. Bare hostnames default tohttp://; you can also paste a fullhttps://example.comif you have HTTPS terminating elsewhere. Chat has no equivalent field: point the backend's ownOLLAMA_URL/ providerURLat wherever the model server actually runs. - Firefox + PDF.js worker (
.mjsMIME type). PDF.js's worker file is a.mjsES module. nginx's stockmime.typesdoesn't list.mjs, so it serves it asapplication/octet-stream, and Firefox refuses to load it as a module — PDF parsing then falls back to a slow main-thread "fake worker" that often fails. Fix: serve.mjswith a JS MIME type. Add this before yourlocation /block:Chrome is more lenient and works without this; Firefox is doing the spec-correct thing.location ~ \.mjs$ { types { } default_type text/javascript; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
The backend authenticates via OpenID Connect — it's an OIDC Relying Party, so you point it at any provider (Keycloak, Authentik, Auth0, …) and it stores no passwords. Every document and chat session is owned by a user; you only ever see your own. Set the OIDC_* vars plus SESSION_SECRET (see .env.example) to turn it on.
- First-user-admin: the first identity to log in becomes admin; everyone after is
pendinguntil an admin activates them (Admin → Users). SetBOOTSTRAP_ADMIN_EMAILto pre-designate the admin by email and inherit any pre-existing single-user data. Caveat: doing so routes that founder through the email-claim path, which does not force-grant capabilities — the pre-designated user must actually hold thereader/chat/adminrealm roles in your IdP (orKC_ADMIN_*must be set for the app to self-heal them), or they log in to "active but no access". See docs/DEPLOYMENT.md Trap 4. - The web app uses a revocable,
HttpOnlysession cookie. The read-aloud extension and scripts use a personal access token sent asAuthorization: Bearer …. Create it under Settings → Account with only the access it needs (the extension needs only Read aloud), then paste it into the extension's Options → Access token. - Local dev without an IdP:
AUTH_ENABLED=falsetreats every request as the admin — but the server refuses to start with this set on a non-loopback bind.
/api/* (Ollama) is never used by the SPA — chat always runs server-side (server/chat/, server/llm/), so the nginx /api/ proxy block should be deleted: Ollama becomes backend-only (loopback bind, nothing proxied). There is no per-user "bring your own Ollama" option in the browser.
The table below is the un-authenticated exposure — i.e. what OIDC now closes for /v1/*. The old /api/* rows (direct Ollama) are gone: the SPA no longer calls them, and the proxy block is removed.
| Endpoint | What an unauthenticated caller can do | Cost to you |
|---|---|---|
POST /v1/synthesize |
Generate arbitrary TTS audio of any length | GPU/CPU burn, electricity |
POST /v1/batch_synthesize |
Submit huge sentence arrays; with Unlimited batch timeout on, a single request can pin Kokoro's inference lock for hours | Same, amplified — practical DoS surface |
/api/* (direct Ollama) is closed now that the SPA goes through the authenticated inference gateway — delete the nginx /api/ block and keep Ollama bound to loopback. If you still proxy /api/* for other clients, everything in the old rows applies: any unauthenticated caller could run any installed model (POST /api/chat) or list models (GET /api/tags) — proxy-gate it or remove it.
CORS no longer helps either: with FRONTEND_ORIGIN unset, server/app.py lets no other website call the API from a browser. (It used to default to allow_origins=["*"], for the read-aloud extension, which let any website drive your Kokoro endpoint from JavaScript. The extension now calls from its service worker and needs no CORS entry.) Set FRONTEND_ORIGIN only if the SPA is served from another origin; * brings the old behavior back. TTS payloads are now size-capped (TTS_MAX_*, see .env.example) so a single request can no longer pin the inference lock indefinitely.
- Chat history, sessions, document library — all in IndexedDB, sandboxed per origin. Other websites can't read them.
- Bind addresses — Kokoro and Ollama listen on
127.0.0.1only by default (both now; Kokoro via run.py). The backend container uses host networking and keeps that127.0.0.1bind, so leaveHOSTunset behind nginx;HOST=0.0.0.0exposes the port on every interface. Only the proxy is internet-facing. - TLS — terminated at nginx with a real cert; in-transit traffic is fine.
- Input shapes — both backends do ML inference. There's no shell-out, no eval, no SQL. The risk is resource consumption, not RCE.
The browser prompts once, remembers credentials for the session, and both backends become useless to anyone without them. Recommended for any single-user deployment.
# One-time: create the password file
sudo htpasswd -B -c /etc/nginx/htpasswd you
# To add another user later, omit -c
sudo htpasswd -B /etc/nginx/htpasswd teammateIn each backend location block:
location /v1/ {
auth_basic "Neural Reader";
auth_basic_user_file /etc/nginx/htpasswd;
proxy_pass http://127.0.0.1:8000/v1/;
# ... existing headers / timeouts ...
}
# /api/ (Ollama) — only if you proxy it for Local-Ollama mode; DELETE this
# block entirely on standard deployments (server-mode chat never touches it).
location /api/ {
auth_basic "Neural Reader";
auth_basic_user_file /etc/nginx/htpasswd;
proxy_pass http://127.0.0.1:11434/api/;
# ...
}sudo nginx -t && sudo systemctl reload nginx. Done.
Zero credentials to manage. Brittle if your IP changes.
location /v1/ {
allow 203.0.113.42; # your home IP
allow 10.0.0.0/8; # internal LAN / VPN
deny all;
proxy_pass http://127.0.0.1:8000/v1/;
# ...
}Caps how fast a single client can hammer the daemons. Useful even for "just you" deployments — keeps a runaway script from pegging the GPU.
In the top-level http {} block of /etc/nginx/nginx.conf:
limit_req_zone $binary_remote_addr zone=tts:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=chat:10m rate=2r/s;Then inside each location:
location /v1/ {
limit_req zone=tts burst=10 nodelay;
# ...
}
location /v1/chat/ {
limit_req zone=chat burst=3 nodelay;
# ...
}Stops other websites from co-opting your TTS via cross-origin fetch. Same-origin requests from your own page are unaffected.
In server/app.py:
app.add_middleware(
CORSMiddleware,
allow_origins=["https://chat.example.com"], # not "*"
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)Reload uvicorn (pkill -HUP -f "uvicorn" or restart the service).
For a more app-like UX than the browser's basic-auth dialog: nginx checks for Authorization: Bearer <token> and returns 401 when absent; the frontend stores the token in localStorage (set once via a small settings field) and threads it through fetch. Better UX, more code on both sides — pursue this if you graduate to multi-user or want the auth to be invisible after first login.
| Deployment | Recommended stack |
|---|---|
| Personal — just you | Basic auth + tighter CORS. Five lines of nginx, one line in server/app.py. |
| Small team / family | Basic auth + rate limit + tighter CORS. Each user gets their own htpasswd entry. |
| Public-ish demo | Basic auth + rate limit + INFERENCE_MODELS allowlist (the gateway never exposes models you don't list) + INFERENCE_DAILY_TOKEN_BUDGET. Consider token auth instead of basic. |
The sample docs/sample_configs/chat.example.com.sample includes the above mitigations as commented-out blocks at the bottom of the file — uncomment what you need and reload nginx.
US Voices (19)
| Voice ID | Name | Gender |
|---|---|---|
af_heart |
Heart | Female (default) |
af_bella |
Bella | Female |
af_alloy |
Alloy | Female |
af_aoede |
Aoede | Female |
af_jessica |
Jessica | Female |
af_kore |
Kore | Female |
af_nicole |
Nicole | Female |
af_nova |
Nova | Female |
af_river |
River | Male |
af_sarah |
Sarah | Female |
af_sky |
Sky | Female |
am_michael |
Michael | Male |
am_adam |
Adam | Male |
am_echo |
Echo | Male |
am_eric |
Eric | Male |
am_fenrir |
Fenrir | Male |
am_liam |
Liam | Male |
am_onyx |
Onyx | Male |
am_puck |
Puck | Male |
UK Voices (8)
| Voice ID | Name | Gender |
|---|---|---|
bf_emma |
Emma | Female |
bf_alice |
Alice | Female |
bf_isabella |
Isabella | Female |
bf_lily |
Lily | Female |
bm_daniel |
Daniel | Male |
bm_fable |
Fable | Male |
bm_george |
George | Male |
bm_lewis |
Lewis | Male |
natural-reader/
├── src/
│ ├── App.jsx # Main application — wires hooks and components
│ ├── main.jsx # React entry point
│ ├── constants.js # Voice definitions, keyboard shortcuts, Ollama defaults
│ ├── db.js # IndexedDB: document library + legacy chat sessions (v3)
│ ├── index.css # Global styles (Tailwind)
│ ├── hooks/
│ │ ├── usePdfEngine.js # PDF + .txt + .md loading, rendering, text extraction, library, extractAllChunks
│ │ ├── useTtsEngine.js # TTS playback loop, caching, voice preview, chat audio channel
│ │ ├── useChatEngine.js # Session state, SSE turn streaming (chatStream.js + chatEvents.js reducer), pins, per-session event log, chat TTS queue
│ │ ├── usePersistedState.js # localStorage-backed state + reading progress
│ │ ├── useKeyboardShortcuts.js
│ │ ├── useMobileDetect.js
│ │ └── useTheme.js
│ ├── lib/
│ │ ├── sessionStore.js # Postgres-or-IndexedDB session dispatcher (legacy IDB sessions → read-only LOCAL badge, imported to Postgres on first send)
│ │ ├── serverDocFile.js # GET /v1/docs/{id}/file → a File the reader opens (Library → Open)
│ │ ├── openDoc.js # Open a server document (Library row or chat citation), then go to a page
│ │ ├── citations.js # "(page N)" citations in replies → buttons (remark plugin)
│ │ ├── chatStream.js # POST a turn + read its SSE response (partial lines, [DONE], abort)
│ │ ├── chatEvents.js # Pure reducer: turn events → message state (text, thinking, tool panel, status)
│ │ └── chatTransport.js # GET /v1/inference/models, budget parsing (no tool code here anymore — tools run server-side)
│ ├── utils/
│ │ ├── attachment.js # File → Attachment helper, size caps, strip-on-save for IndexedDB
│ │ ├── docHash.js # sha256 of file bytes → stable doc_id (Web Crypto, lazy + memoized)
│ │ ├── markdownToSpeech.js # Strips Markdown markup before chat TTS synthesis
│ │ ├── url.js # Builds API URLs, returns relative paths when host is blank
│ │ └── wavConcat.js # Stitches multiple PCM WAV blobs into one (audiobook export, v1.7)
│ └── components/
│ ├── Header.jsx # Top toolbar with Reader/Chat toggle + playback + audiobook + home + distraction-free
│ ├── HeaderOverflowMenu.jsx # `⋯` dropdown for secondary actions on mobile (sm:hidden)
│ ├── Sidebar.jsx # Reader sidebar: sentence list, chapters, settings, voice picker
│ ├── ChatSidebar.jsx # Chat sidebar: model picker (grouped by provider), sessions (with LOCAL badge), log — no host/port config; providers are a server .env setting
│ ├── PdfViewer.jsx # Branches between PDF canvas / TextPageRenderer / MarkdownReader; toolbar hosts Ask page, Index, Convert, PDF|MD toggle
│ ├── IndexButton.jsx # Toolbar control: idle → uploading → indexing N/M → indexed (or failed)
│ ├── ConvertButton.jsx # Toolbar control for docling conversion lifecycle (idle → uploading → converting → converted)
│ ├── DoclingConvertDialog.jsx # Options modal (quality preset, OCR, tables, images, page range)
│ ├── MarkdownReader.jsx # Reader view for docling-converted documents (per-page anchors + page-nav sync)
│ ├── TextPageRenderer.jsx # Renders a .txt pseudo-page with sentence highlighting
│ ├── MarkdownPageRenderer.jsx # Renders a .md page (react-markdown + remark-gfm) with paragraph-level highlighting
│ ├── ChatView.jsx # Chat list, prompt box, attachments, DocContextChip, ToolCallsDisclosure, per-message stats / read-aloud / copy / audio download
│ ├── AttachmentPreview.jsx # Image-thumbnail / audio-player chip used in pending bar + bubbles
│ ├── VoiceRecorder.jsx # MediaRecorder UI (kept for future re-enable; currently unused)
│ ├── MobileBottomNav.jsx
│ ├── WelcomeScreen.jsx # Library and upload landing page
│ └── overlays/ # Drag, toast, context menu, shortcuts modal, ReadSelectionButton (+ Ask AI)
├── server/
│ ├── __init__.py
│ ├── app.py # FastAPI app factory — mounts TTS + chat_sessions/chat_turns + docs routers, runs init_db + start_router on startup
│ ├── db.py # psycopg async pool + migration runner + pgvector codec registration
│ ├── endpoints.py # /v1/synthesize, /v1/batch_synthesize, /v1/health
│ ├── model.py # Kokoro ONNX loading with GPU/NPU/CPU fallback
│ ├── schemas.py # Pydantic request models (TTS)
│ ├── sql/
│ │ ├── 001_init.sql # Core schema: documents, doc_chunks (vector(768)), chat_sessions, chat_messages, chat_events
│ │ ├── 002_tool_calls.sql # Adds tool_calls JSONB to chat_messages
│ │ ├── 003_docling.sql # Docling lifecycle columns on documents + new doc_pages table
│ │ ├── 005_users_ownership.sql # users table + per-user ownership (multi-user)
│ │ ├── 006_auth_credentials.sql # sessions + personal_access_tokens
│ │ └── 007_inference_budgets.sql # inference_usage + per-user daily token budget
│ ├── routers/
│ │ ├── chat_sessions.py # /v1/chat/sessions/* — list / get / patch / delete / import (legacy-chat copy)
│ │ ├── chat_turns.py # POST /v1/chat/sessions/{id}/turns — runs one turn, frames orchestrator events as SSE
│ │ ├── docs.py # /v1/docs/* — upload / list / status / file / shares / index / search / convert / markdown
│ │ ├── inference.py # GET /v1/inference/models — every provider's models + the caller's budget
│ │ ├── admin.py # /v1/admin/* — user management + inference usage + deployment config view
│ │ └── auth.py # /v1/auth/* — OIDC login/callback, sessions, PATs
│ ├── chat/ # The chat orchestrator (server-side turn loop, C1)
│ │ ├── orchestrator.py # run_turn: one step per model call, tool rounds, failures, event emission
│ │ ├── context.py # Stage 0: document prefetch, time-in-prompt, history trimming
│ │ ├── store.py # Turn claims/heartbeat/recovery, message + event persistence
│ │ ├── config.py # CHAT_* env knobs (tool rounds, prefetch, trimming, request size cap)
│ │ └── tools/ # search_documents, read_document_pages, extract_from_documents, document_outline, web_search, read_web_pages, offer_web_search — one file per tool, server-side only
│ ├── llm/ # Model provider layer (C1)
│ │ ├── router.py # Loads INFERENCE_PROVIDERS config, resolves "<provider>:<model>" ids
│ │ ├── types.py # Internal message/event types every adapter maps to/from
│ │ └── providers/ # ollama.py, openai_compat.py adapters + shared base.py helpers
│ └── services/
│ ├── embeddings.py # httpx client → Ollama /api/embeddings via model_router, Semaphore(4), dim assertion
│ ├── model_router.py # Legacy single-Ollama config: allowlist, task models (summarize/embed), daily budget
│ ├── inference_budget.py # per-user daily token accounting (UTC days) — also charges stopped/failed replies
│ ├── web_search.py # SearXNG results + SSRF-guarded page fetch and passage picking
│ └── docling_convert.py # PDF → per-page Markdown via Docling (Fast/Standard/Accurate presets)
├── data/
│ └── pdfs/ # Retained PDF bytes (one file per doc_id; created on first conversion)
├── docker-compose.yml # pgvector/pgvector:pg16 on host port 5433
├── docs/
│ ├── CHAT_WITH_PDF.md # End-to-end walkthrough for the doc-chat / RAG / tool-calling feature (+ §10 perf)
│ ├── RELEASE_NOTES_v1.7.0.md # Tag-page notes for v1.7.0
│ ├── RELEASE_NOTES_v1.7.1.md # Tag-page notes for v1.7.1
│ ├── DEPLOYMENT.md # Production behind nginx + TLS: topology, config table, the traps
│ ├── chat.example.com.sample # Prod nginx vhost — SPA + same-origin /v1 proxy (TLS, hardening recipes)
│ └── auth.example.com.sample # Prod nginx vhost — reverse-proxies Keycloak (auth.example.com)
├── run.py # Server entry point (uvicorn) — honours WORKERS / HOST / PORT env vars
├── requirements.txt # Python dependencies
├── vite.config.js # Vite + Rolldown config with chunk splitting
├── tailwind.config.js
├── package.json
└── index.html
npm run dev # Start Vite dev server
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
# How well does a chat model answer questions about a document? Runs the real
# chat path against a document with planted facts (needs Postgres, the
# embedding model and the chat model; see docs/CHAT_WITH_PDF.md §6.5).
python scripts/eval_doc_qa.py --model ollama:llama3.2:3b- Resume Reading — Reopen the same PDF to automatically continue from your last position
- Library — Recent books are persisted in IndexedDB — click to instantly resume
- Keyboard Navigation — Use keyboard shortcuts for hands-free control
- Prefetching — The next 2 sentences are pre-fetched for seamless playback
- Dark Mode — Toggle with the moon/sun icon or
Ctrl + D - Right-Click Menu — Right-click any sentence for "Continue from here" and copy options
- Read Selection — Select text on the PDF, then click the floating "Read Selection" button
- Download Audio — Export the current page's audio as a WAV file for offline listening
- Drop / paste an image — Drop an image file anywhere on the chat view, or
Ctrl + Va screenshot directly into the prompt textarea. Multiple images per message are supported. - Model stats — Click the small
⚡chevron under any reply to expand a per-turn breakdown of load time, prompt eval, generation throughput, anddone_reason. Useful for comparing models or spotting cold-start cost. - Scroll while streaming — Scroll up at any time during a reply; the chat will stop tailing tokens and let you read history. Scroll back near the bottom and tailing resumes automatically.
- Sessions — Every chat is saved automatically; switch / rename / delete from the sidebar. Click "New chat" to start a fresh thread without losing the current one.
- Reasoning models — For
deepseek-r1,qwen3-thinking, etc., enable "Thinking" in the chat sidebar. The reasoning trace appears in a collapsible disclosure above the answer (auto-expanded while streaming).
The backend logs to both the console and a size-rotating file at
logs/server.log (10 MB × 5 backups; logs/ is gitignored). This captures TTS,
chat, embedding, and web_search activity — including SSRF blocks, failed or
slow page fetches, and the reader's failed or timed-out calls and dropped facts (counts and error types, never page text). Tune it with env vars (see .env.example):
| Var | Default | Controls |
|---|---|---|
LOG_LEVEL |
INFO |
Verbosity (DEBUG / INFO / WARNING / …) |
LOG_DIR |
./logs |
Directory for the log file |
LOG_FILE_ROLL_OVER_TIME |
midnight |
When a new log file starts: midnight, H (hourly), D, W0–W6 (weekly) |
LOG_FILE_BACKUPS |
5 |
How many old files to keep (at midnight: days) |
Frontend logs stay in the browser devtools console; Postgres and SearXNG keep their
own container logs (docker-compose logs <service>).
The project uses Rolldown (via rolldown-vite) with optimized chunk splitting:
- vendor-react — React core split into a separate chunk for long-term caching
- vendor-pdfjs — PDF.js bundled locally (no CDN dependency)
- vendor-icons — Lucide icons isolated for efficient tree-shaking
Contributions are welcome! See CONTRIBUTING.md for setup, the
test/lint commands, branch and commit conventions, and how larger features are
designed. docs/ARCHITECTURE.md explains how the
components fit together (the same-origin /v1 rule, auth flow, TTS lock, chat
engine, doc pipelines, and the invariants to keep intact). In short:
./startup.sh init && ./startup.sh up, keep both test suites
green (npm run test:run and .venv/bin/pytest server/tests), and open PRs against
master using Conventional Commits.
Released under the MIT License © 2026 Udara Vimarsha.