Nox / README.md
umer-wasim's picture
Unpin Nox; Arx is pinned instead
a8ce2ec verified
|
Raw History Blame Contribute Delete
13.8 kB
metadata
title: Devseis Chat
emoji: πŸ’¬
colorFrom: blue
colorTo: indigo
sdk: static
pinned: false
license: apache-2.0

Nox β€” Devseis's free AI assistant

Nox is a general-purpose AI chat assistant built for Devseis to a hard $0/month operating constraint. Everything below runs on free tiers β€” no paid APIs, no rented servers, no billing surprises. This file is meant to fully brief a new session (human or AI) on what exists today and how it works, without needing local machine access.

Architecture

  • Frontend: this HF Space (Devseis/Nox), sdk: static β€” a single index.html (plain HTML/CSS/vanilla JS, no framework, no build step) plus a small service worker (sw.js) for PWA installability. Served at devseis-nox.static.hf.space.
  • Backend: Cloudflare Worker devseis-chat-proxy (devseis-chat-proxy.umer-wasim.workers.dev), source lives outside this repo (deployed via wrangler, see Deploying changes below β€” it is not tracked in this HF repo).
  • Storage: one Cloudflare KV namespace (USAGE_KV) β€” usage counters, feedback records, and server-side RAG document chunks (all with TTLs, nothing kept forever).
  • Models: Cloudflare Workers AI (@cf/meta/llama-3.2-1b-instruct "Quick", @cf/meta/llama-3.2-3b-instruct "Standard", @cf/meta/llama-3.1-8b-instruct-fp8 "Smart"), @cf/llava-hf/llava-1.5-7b-hf for vision, @cf/baai/bge-m3 for embeddings.

Features, and how each is actually built

Chat

Standard streamed chat against Cloudflare Workers AI (SSE), with three model tiers the visitor can pick from. A daily message cap (DAILY_MESSAGE_CAP = 1000) protects the free Neuron allocation; a crisis-keyword regex (CRISIS_RE) intercepts self-harm language before any model call and returns a fixed, sourced response (SOS DΓ©tresse Luxembourg / 112) β€” a hard code-level guard, not a prompt instruction, because prompt-only guards have been proven to fail under sustained conversational pressure (see Lex's history below).

Live data grounding (no paid search API)

Regex intent-matchers in the worker route specific question types to free, deterministic APIs instead of letting the model guess or hallucinate:

  • Weather / air quality / moon phase β†’ Open-Meteo
  • News β†’ BBC RSS
  • Currency conversion β†’ Frankfurter.app (ECB rates)
  • Luxembourg public holidays β†’ date.nager.at
  • Current time β†’ deterministic timezone math
  • General facts / population / office-holders β†’ DuckDuckGo Instant Answer + Wikipedia REST summary + Wikidata claims (with entity-description disambiguation so "mayor of Luxembourg" the city doesn't resolve to a head of state of the wrong entity)

Google/Bing search and any paid maps/places API were ruled out by the cost constraint; DuckDuckGo's own HTML search page was tested directly and rejected (anti-bot challenge on automated requests) before landing on the Instant Answer API.

Documents β€” server-side RAG

Upload a PDF/text document while in RAG mode β†’ the worker chunks it (RAG_CHUNK_SIZE = 800 chars, 100 overlap), embeds each chunk (bge-m3), and stores {text, vector, name} in KV, scoped by conversation id, TTL 24h, capped at 300 chunks/ conversation. Every later question in that conversation gets its own question embedded and compared via in-Worker cosine similarity (deliberately not Vectorize β€” a chat- uploaded document is a handful of pages, a plain JS loop is simpler and fast enough at this scale). Replaces an older design that stuffed up to 12,000 chars of raw document text into every message.

  • Adjustable retrieval breadth: sidebar "Chunks per answer" (1–8, ragTopK in the request body, clamped server-side) β€” shared by both server and offline RAG.
  • Source transparency: every reply that used retrieved excerpts gets a "πŸ“Ž N" badge; clicking it shows each chunk's document name, closeness score, and a text preview. Delivered via an X-Rag-Chunks response header on the streamed reply (headers arrive before the body finishes, so this doesn't require touching the raw SSE stream) or a ragChunks JSON field on the buffered fallback path; CORS Access-Control-Expose-Headers had to explicitly list it or the browser hides it.
  • Manage indexed documents: sidebar "πŸ“š View" (only shown once β‰₯1 document is indexed, hidden again once the last one is removed) lists every document with its chunk count and a running total ("N documents β€” M chunks / M embeddings" β€” one embedding per chunk, always, so that's really one number shown two ways), with a Remove button per document. Removing a document is just filtering it out of the flat chunk list and re-saving β€” there's no separate index to rebuild.
  • Multi-file upload: selecting several files at once indexes each individually with combined progress/summary feedback.

Documents β€” fully offline RAG (local mode)

When "Local AI mode" is active (see below), RAG works identically but entirely client-side: chunking mirrors the server's algorithm exactly, embedding uses Xenova/all-MiniLM-L6-v2 via @huggingface/transformers on WebGPU, and chunks are stored in IndexedDB instead of KV. The document never leaves the device. Source transparency and the adjustable top-K setting both work here too, computed from the same ragTopK value.

Local AI mode (offline fallback)

A toggle (or automatic offer, once the free server allocation is exhausted for the day) loads Llama-3.2-1B-Instruct-q4f16_1-MLC via WebLLM directly in the browser (requires WebGPU). Chosen deliberately small so it downloads and runs on phones, not just desktops β€” a real capability gap vs. the server's best model, disclosed to the user in an in-app tooltip, not hidden.

Two real bugs were found and fixed here through actual testing, not by inspection:

  • Context-window overflow: the local model's real context window is 4096 tokens. Chat mode's conversation history was never bounded the way the server path is (capped at the last 20 messages server-side) β€” a conversation with even one document attachment kept growing every turn until it hit ContextWindowSizeExceededError, which then crashed the WebGPU device entirely ("Device was lost"), breaking every later local message until a full page reload. Fixed with trimHistoryForLocalEngine() (keeps the system message + as many recent messages as fit a conservative chars/2.5-per-token budget, always keeping at least the message just sent), a lower one-shot-document cap (2,500 chars, down from an initial 4,000), and a catch block that tells context-overflow and device-loss apart from generic errors, clearing the cached engine reference on device loss so the next attempt auto-recovers instead of staying broken.
  • Double-wrapped attachments: one-shot document text was being labeled [Attached file: name] at file-select time AND wrapped again at send time, on every attachment (not just multi-file) β€” a real, confusing malformed-prompt bug that had been produacing part of what first looked like pure "the model is too small" garbage output. Fixed by only pre-labeling per-file when multiple files are combined into one message, and skipping the outer wrapper in that case.

Local mode cannot read images (text-only engine β€” a real capability gap, not a size-tunable limit).

Reliability

If Cloudflare Workers AI returns a confirmed Neuron-exhaustion error, the worker transparently falls back to Google Gemini's free tier (gemini-flash-lite-latest β€” the "-latest" bigger alias was seen hitting real 503 "high demand" errors in testing, the lite variant didn't), then OpenRouter's free tier (nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free β€” OpenRouter's own auto-router picked models that returned empty content more often than not; this one didn't), each with an 8s timeout so one slow provider can't stall the whole request. Scoped to the text chat path only β€” the vision endpoint already degrades gracefully on its own and would need real separate per-provider multimodal handling to extend this to it.

OpenAI, scoped narrowly, unlike Lex: OPENAI_API_KEY is set on this worker too, but tried first only when the reply is genuinely grounded in retrieved document excerpts (ragChunksMeta.length > 0 β€” a document was actually uploaded and something relevant was found for this question), streamed via callOpenAIStream the same way Cloudflare's own reply streams. Plain chat and live-data questions never touch it and stay entirely on the free Cloudflare tiers regardless of which model tier the visitor picked β€” a deliberate choice to keep OpenAI's real cost scoped to where it was shown to matter most (synthesizing several retrieved excerpts faithfully) rather than all of Nox's traffic the way it ended up scoped for Lex. Capped at OPENAI_DAILY_CALL_CAP = 200/day (KV-tracked, separate from DAILY_MESSAGE_CAP), a smaller number than Lex's cap since this only fires for document-grounded replies, not every message.

Everything else

  • PWA: installable, small app-shell cache via sw.js (network-first for index.html so a redeploy is visible immediately when online; falls back to cache only when genuinely offline).
  • Multi-language: UI + reply language for English, French, German, Luxembourgish, Portuguese; speech-to-text input and text-to-speech output (Luxembourgish has no browser TTS/STT support anywhere, so it maps to English rather than silently failing).
  • Sharing/export: read-only share links, QR code, text/PDF export.
  • Feedback: πŸ‘/πŸ‘Ž per reply, stored in KV.
  • Privacy: a visitor id (not an account) is used only for daily-cap/unique-user counting; local mode and offline RAG keep data entirely on-device; dedicated privacy.html page.

Deploying changes

  • Frontend: edit index.html (or other files) in this repo, git commit, git push β€” the HF static Space rebuilds and is live within seconds. There is no staging/preview for the frontend; test locally (a plain python3 -m http.server plus a headless-browser smoke test against a Cloudflare preview worker version has been the working pattern) before pushing.
  • Worker: source is NOT in this repo. Deploy via wrangler versions upload first (produces a preview URL, e.g. https://<version>-devseis-chat-proxy.umer-wasim.workers.dev) to test against real Cloudflare infra without touching production, then wrangler versions deploy <id> --yes to promote to 100% traffic. Never wrangler deploy directly for changes to the live service.
  • Pulling the current live worker source or settings requires a Cloudflare API token, read from ~/Library/Preferences/.wrangler/config/default.toml via a Python script (not an inline bash one-liner β€” sandboxed shells tend to block inline commands that read the token and curl with it, even for the account's own resources).

Known limitations (disclosed, not hidden)

  • The local 1B model's summarization/instruction-following quality is noticeably worse than the server models β€” expected from a model this size, not a bug. RAG mode generally gives more reliable answers than local Chat mode's one-shot document dump, since it retrieves small named excerpts rather than asking the model to hold and reason over a whole document in one pass.
  • No automated test suite β€” changes are verified by real preview-URL/browser testing before promotion, not by a CI pipeline.

Future plan: document β†’ fine-tuned model pipeline

Goal: a visitor uploads documents, the app generates synthetic Q&A training examples from them, trains a LoRA adapter, and serves it back for download β€” still $0/month.

Chosen approach: Hugging Face Spaces + ZeroGPU (shared, free, on-demand H200 access, GPU allocated only while a decorated @spaces.GPU function runs). This requires a Gradio Space β€” Nox's current frontend is static HTML, not Gradio, so this cannot be bolted onto the existing Nox Space. It would need to be built as a separate, companion HF Space (still free to host) rather than a feature added to Nox itself.

The real open risk, not yet validated: ZeroGPU's daily quota is small β€” 120 seconds/day for an unauthenticated visitor, 210 seconds/day for a free signed-in HF account, 1,500 seconds/day for HF PRO ($9/month). A single LoRA training call alone could plausibly need most or all of that budget, before accounting for the synthetic-example-generation step (also LLM inference, ideally also GPU-backed to stay free). Paying for HF PRO to make this practically usable would also break the $0 constraint. Before any further build work: time a real LoRA training call against the smallest realistic base model and dataset, and check whether it fits inside the 120–210 second window at all. That single number determines whether this pipeline is viable as designed, or needs a fundamentally different approach (e.g., training asynchronously across multiple queued GPU calls with real checkpoint save/resume, which is a meaningfully bigger build than currently sketched).

Stages, at a glance (see prior planning notes for full detail): upload (PDF/DOCX/TXT, chunked like RAG) β†’ synthetic Q&A generation per chunk via an LLM call β†’ dataset format/validation β†’ @spaces.GPU-wrapped LoRA training (peft + transformers) β†’ package adapter_model.safetensors + adapter_config.json for download. Optional, separate follow-on: serving the resulting adapter via Cloudflare Workers AI has its own constraints (allowed base models, rank ≀ 8, exact filenames, model_type field added manually, <100MB) that should be re-verified against current Cloudflare docs before relying on them, since they weren't independently confirmed when first written down.