⚠️ Vibe-coded project — this code was written by an AI assistant without formal review or testing. It works for its intended purpose, but use it with adequate care: don't paste sensitive data into it, and review the code before trusting it with anything important.
A minimal web UI for chatting with a local LLM server on your LAN.
Works with any OpenAI-compatible server: LM Studio, Unsloth Studio, llama.cpp llama-server (and friends like Ollama).
| Mode 1 — single file | Mode 2 — login & multi-user | |
|---|---|---|
| What you run | just index.html |
docker compose up -d --build (or plain node server.js) |
| Setup | none — open the file or serve it statically | one Docker container on your NAS |
| Users | 1 (your browser) | up to 10, each with username + password |
| Where history lives | your browser's localStorage |
on the server, per user (./data) |
| Best for | personal use, quick testing | a shared NAS that several people use |
Both modes share the same UI and features; Mode 2 simply adds an account system around it. The app detects automatically which mode it's in — no configuration needed.
- Enter the server URL (e.g.
http://192.168.1.50:1234/v1) and go — model list is fetched from/v1/models - Token streaming (SSE) with a Stop button, a typing indicator and a blinking caret while the answer streams in
- Reasoning shown in a collapsible block (reads
reasoning_content, fallbackreasoning) - Light & dark themes — follows your OS setting (
prefers-color-scheme), no toggle needed - Persistent history: multiple conversations saved in your browser's
localStorage(new / switch / rename / delete — click a chat to reveal its action icons; deleting asks for confirmation). The chat list is grouped by date (Today / Yesterday / Last 7 days / Earlier) - Collapsible chat list: the menu button in the header hides/shows the sidebar so the conversation gets the full width — on small screens (≤ 760px, e.g. smartphones) it starts hidden by default; your choice is remembered per browser
- Save / load to file: select a chat in the sidebar — the download button saves it as JSON (incl. reasoning, re-importable via Import),
MDexports it as Markdown (reasoning is omitted) - Regenerate / branch: on the last answer, regenerate (replaces it); on one of your messages, branch off into a new chat with the history up to that point (ending at the previous complete answer — your message is not carried over). On touch devices these actions are always visible
- Inline error handling: a failed request stays in the chat as a red error bubble with a Retry button; an interrupted stream keeps the partial answer and notes it was cut off
- Context usage bar: shows estimated token usage of the current chat vs. the model's context length (read from
/v1/models), turns yellow at 70% and red at 90%, with a warning near/over the limit; hidden while usage is low - Optional API key and system prompt, both persisted — edited in a settings dialog (⚙ / Esc closes)
- Minimal offline markdown rendering — headings, bullet/ordered lists, tables, blockquotes, code blocks, inline code, bold/italic, links, and LaTeX math (
$\rightarrow$,$\alpha$,$x^2$, …) converted to Unicode. No external libraries; everything is escaped (no raw HTML). Code blocks get a language label and a Copy button - Mobile-friendly: two-row header, full-width model picker, touch-friendly action buttons, no iOS input zoom; a scroll-to-bottom button appears while reading during long replies
- Mode 2 only: username + password login, per-user chat history stored on the server, up to 10 users by default (see below)
- Admin user (Mode 2): the
INITIAL_USERaccount gets a Users button in the header that lists all registered users with their last login date/time
Just open index.html. Any static file server works:
cd ChattyLAN
python3 -m http.server 8000
# → open http://localhost:8000You can also just double-click index.html and open it directly — the servers allow
cross-origin requests by default, so file:// works too. History lives in your
browser's localStorage.
The recommended way to share ChattyLAN with other people on your network. One container, no dependencies, no build step beyond the image build:
cd ChattyLAN
cp .env.example .env # optional — set DATA_KEY / INITIAL_USER etc. (gitignored)
docker compose up -d --build
# → open http://<nas-ip>:8080 and create your first accountOn a Synology NAS: Container Manager → Project → Add, point it at this folder
(it picks up docker-compose.yml automatically). The image is plain node:alpine,
so any Docker-capable NAS works. Chat data persists in ./data (a volume), so
upgrades and container rebuilds don't lose anything. Inside the container the
server listens on port 8080 (unprivileged); the ports: mapping exposes it on
the host's 8080.
On first visit you create an account (or pre-create one via INITIAL_USER /
INITIAL_PASSWORD in .env); registration stays open until 10 accounts exist,
then closes automatically. Each user gets their own settings and chat history,
stored under ./data/users/.
The INITIAL_USER account is the admin: it sees a Users button next to its
name in the header, which opens a list of all registered users with their last
login date/time. If the account already existed before this feature was added,
it is promoted to admin automatically on the next start.
Chats sync across devices for the same account: the server keeps a revision
counter and the UI polls it (cheap ETag/304 checks, every few seconds and on
tab focus), so a conversation open on your phone and your laptop stays in step.
No Docker? Run the backend directly with Node.js (≥ 18)
cd ChattyLAN
node server.js # → open http://localhost:80 (or PORT=8080 node server.js)Same behavior, same env vars — data goes to ./data next to server.js.
When running via Docker, these are read from a .env file next to
docker-compose.yml — copy .env.example to .env and fill it in (.env is
gitignored, so secrets never end up in the repo). Useful environment variables:
| Variable | Default | Meaning |
|---|---|---|
PORT / HOST |
80 / 0.0.0.0 |
listen address (docker-compose sets PORT=8080 inside the container — unprivileged, so it works on Docker daemons that forbid binding ports below 1024; reach it via the ports: mapping) |
DATA_DIR |
./data |
where users/sessions/chat data are stored |
MAX_USERS |
10 |
registration cap |
SESSION_DAYS |
7 |
session idle timeout (sliding — any activity extends it) |
DATA_KEY |
off | if set, per-user chat data is encrypted at rest (AES-256-GCM) |
ALLOW_REGISTER |
on | set to 0 to disable self-registration |
INITIAL_USER / INITIAL_PASSWORD |
— | pre-create an account on first boot (password ≥ 8 chars); this account is the admin (Users button) |
FORCE_SECURE |
off | set to 1 if TLS is terminated in front of the server |
Security notes: passwords are hashed with scrypt (per-user salt, constant-time
compare), sessions use random 256-bit tokens in HttpOnly; SameSite=Lax cookies,
and there's a per-IP lockout after 5 failed logins. Set DATA_KEY to encrypt chat
data at rest (AES-256-GCM); without it the per-user data files are plain JSON —
they also contain the upstream LLM API key you entered in the settings, so
setting DATA_KEY is recommended. All data files are written atomically and with
0600 permissions; if users.json is ever found corrupt it is moved to
users.json.corrupt-* and the server refuses to start rather than silently
overwriting it (restore or delete that file). Back up the ./data folder regularly.
This is "simple yet secure" for a trusted LAN — it is not hardened against
internet exposure (put it behind TLS if you do that).
| Server | URL to enter | Notes |
|---|---|---|
| LM Studio | http://<ip>:1234/v1 |
Start the local server in LM Studio; CORS is open by default |
| llama.cpp | http://<ip>:8080/v1 |
Bind to LAN: llama-server -m model.gguf --host 0.0.0.0. Reasoning: default --reasoning-format auto usually populates reasoning_content; force it with --reasoning-format deepseek. Optional auth: --api-key KEY (enter the key in the UI) |
| Unsloth Studio | http://<ip>:<port>/v1 |
Enable its built-in server; same OpenAI-compatible API |
If a server ever blocks cross-origin requests, set it to allow your browser's origin
(e.g. llama.cpp: --cors-origins http://localhost:8000) or serve this UI from the
same machine.
- Mode 2 (Docker /
server.js): settings + chat history are stored per user underDATA_DIR/users/<user>.json— this includes the upstream LLM API key entered in the settings dialog. Nothing leaves your LAN except the chat requests themselves. Deleting a user's file wipes their data. SetDATA_KEYto encrypt these files at rest (AES-256-GCM); by default they are plain JSON. - Mode 1 (static hosting /
file://): everything lives in your browser'slocalStorageunder the keyschatty.settings,chatty.chats,chatty.activeandchatty.nav(sidebar open/closed state).
Note: localStorage is per browser and per origin (file:// vs http://localhost:8000
are different stores), and has a ~5–10 MB quota. If it fills up, the app shows a warning
bar — export chats to file (download / MD) or delete old ones. File export/import has no size
limit, so that's also the way to keep very large conversations: export → free space in
the browser → re-import later via Import.
