diff --git a/.env.example b/.env.example index 55da29c..7dbb9f0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # --- server --- +# development locally; the NixOS module sets production (gates DRIVER_DEBUG). +NODE_ENV=development PORT=3000 HOST=127.0.0.1 # ADMIN access key (plain). This is the admin account's key — it manages all @@ -9,16 +11,33 @@ APP_PASSWORD=change-me SESSION_TTL_HOURS=720 # Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares) DATA_DIR=./data +# Local file CDN (avatars). NixOS module points it under the state dir. +CDN_DIR=./cdn # Local file CDN (avatars today, more namespaces tomorrow). # Dev default is a folder next to the codebase; NixOS points it under state dir. CDN_DIR=./cdn +# --- platform tools: OCR (tesseract binary) --- +# TESSERACT_BIN=/nix/store/…-tesseract-5.5.3/bin/tesseract +OCR_LANG=eng +OCR_MAX_CHARS=100000 + # Per-driver debug logging to stdout ([driver:name] lines). DRIVER_DEBUG=true # Public docs wiki (Gitea wiki pages of this repo). GET /wiki redirects here. WIKI_URL=https://git.kisakay.com/k/chat/wiki +# --- key recovery via email (optional — empty SMTP_HOST disables it) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=KisAssistant +APP_URL=http://localhost:3000 +RESET_TTL_MIN=60 + # --- rate limit on /api/auth/login (per IP, sliding window) --- RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_MIN=10 diff --git a/README.md b/README.md index 4a994b3..6ee8300 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Driver priority: Docs: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (backend, drivers, DB) · [`docs/FRONTEND.md`](docs/FRONTEND.md) (components, theming) · -[`docs/OPERATIONS.md`](docs/OPERATIONS.md) (env, dev, accounts, NixOS). +[`docs/OPERATIONS.md`](docs/OPERATIONS.md) (env, dev, accounts, NixOS) · +[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md) (server hosting tutorial). Agent instructions: [`AGENTS.md`](AGENTS.md). ## Quick start @@ -90,10 +91,12 @@ Enable an API driver later: `MISTRAL_ENABLED=true` + `MISTRAL_API_KEY=…`, rest ## NixOS hosting (chat.kisakay.com) +Full tutorial: **[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md)**. + Flake exposes `packages..default` (backend + prebuilt frontend) and -`nixosModules.default`. The frontend is built offline via `buildNpmPackage` -(`frontend/package-lock.json`); on first build, replace the placeholder -`npmDepsHash` with the hash Nix suggests. +`nixosModules.default`. The frontend is built offline inside Nix with Bun from +`frontend/bun.lock`; on a deps hash mismatch, paste the `got: sha256-…` value +Nix reports into `bunDepsHash` in `flake.nix`. ```nix kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path @@ -101,15 +104,18 @@ kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path imports = [ kisassistant.nixosModules.default ]; services.kisassistant = { enable = true; - package = kisassistant.packages.${pkgs.system}.default; - domain = "chat.kisakay.com"; # nginx reverse proxy + ACME + package = kisassistant.packages.${pkgs.system}.default; # required + enableNginx = true; # nginx reverse proxy + ACME + domain = "chat.kisakay.com"; # vhost name (required with enableNginx) passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix ollamaHost = "http://10.66.66.4:11434"; }; +security.acme.acceptTerms = true; # required for the TLS certificate ``` -Nginx terminates TLS; the app listens on `127.0.0.1:3000`. SQLite lives in -`/var/lib/kisassistant/data`. +Nginx terminates TLS; the app listens on `127.0.0.1:3000` with +`NODE_ENV=production`. SQLite lives in `/var/lib/kisassistant/data`, avatars +(file CDN) in `/var/lib/kisassistant/cdn`. ## Frontend diff --git a/bun.lock b/bun.lock index 8e4d61d..26744a3 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,12 @@ "workspaces": { "": { "name": "kisassistant", + "dependencies": { + "nodemailer": "^10.0.10", + }, "devDependencies": { "@types/bun": "latest", + "@types/nodemailer": "^8.0.2", }, }, }, @@ -14,8 +18,12 @@ "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + "@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "nodemailer": ["nodemailer@10.0.10", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 516eba8..b3cf4f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,8 +43,9 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so). | Table | Purpose | |---|---| -| `users` | `id, username UNIQUE, display_name, avatar_url, theme, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). | +| `users` | `id, username UNIQUE, display_name, avatar_url, theme, email, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). | | `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. | +| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). | | `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. | | `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. | | `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. | @@ -80,6 +81,16 @@ exists: key = account id (`/cdn/avatar/.png`), ownership enforced. - Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`, `Cache-Control: public, max-age=3600`. +## Platform tools + +`src/tools/`: `PlatformTool` interface + registry (today: `ocr` via the +tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes +raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns +`{ text, truncated, chars }` — transcription happens server-side with +per-driver-style stdout logging; a missing binary yields a clean 501. Text +attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model +only ever sees reviewed text blocks, never raw files. + ## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), `DriverRegistry` with global priority `ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 0dc35ca..eac8863 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -37,7 +37,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. chip (opens settings), Accounts button (admin), logout. - `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`, share button), message list (`Markdown` for assistant, emerald bubble for - user), rounded composer (Enter to send, Shift+Enter for newline). + user), rounded composer with paperclip attach menu, attachment chips + (Enter to send, Shift+Enter for newline). - `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link, copy, unshare), `SettingsModal` (display name, avatar upload box with drag & drop, avatar URL, theme). diff --git a/docs/NIXOS-HOSTING.md b/docs/NIXOS-HOSTING.md new file mode 100644 index 0000000..83cacae --- /dev/null +++ b/docs/NIXOS-HOSTING.md @@ -0,0 +1,231 @@ +# Hosting KisAssistant on a NixOS server + +End-to-end guide: build the flake, wire the module into `configuration.nix`, +secure it with agenix/sops-nix, and put it behind nginx with automatic TLS. + +Contents: + +1. [Prerequisites](#1-prerequisites) +2. [Add the flake to your system inputs](#2-add-the-flake-to-your-system-inputs) +3. [Secrets (`APP_PASSWORD`, API keys)](#3-secrets-app_password-api-keys) +4. [Enable the service](#4-enable-the-service) +5. [TLS reverse proxy (`enableNginx`)](#5-tls-reverse-proxy-enablenginx) +6. [First deploy & smoke test](#6-first-deploy--smoke-test) +7. [Operations: logs, backups, updates](#7-operations-logs-backups-updates) +8. [Module option reference](#8-module-option-reference) + +--- + +## 1. Prerequisites + +- A NixOS machine (unstable channel or a flake lock close to it) with + ` networking` + `firewall` under your control. +- Bun is **not** installed on the server — the flake wraps the runtime. +- DNS: an A/AAAA record for your domain (e.g. `chat.kisakay.com`) pointing at + the server. ACME (Let's Encrypt) needs port 80/443 reachable during + issuance. +- Ollama reachable from the server (e.g. `http://10.66.66.4:11434` on your + LAN/tailscale). + +## 2. Add the flake to your system inputs + +In your system flake (or `flake.nix` of your NixOS config): + +```nix +{ + inputs.kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path + + outputs = { self, nixpkgs, kisassistant, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ./configuration.nix + kisassistant.nixosModules.default + ]; + }; + }; +} +``` + +If you don't use flakes for your system config, import the module by path: + +```nix +imports = [ /etc/nixos/kisassistant/module.nix ]; +# and point the package at a build: +services.kisassistant.package = + (builtins.getFlake "git+http://git.kisakay.com/k/chat").packages.x86_64-linux.default; +``` + +> The frontend is compiled **offline inside the Nix build** with Bun from +> `frontend/bun.lock` (fixed-output deps fetch). If `nix build` ever fails with +> a `bunDepsHash` mismatch, paste the `got: sha256-…` value from the error into +> `bunDepsHash` in `flake.nix` and rebuild. This is normal after dependency +> bumps. + +## 3. Secrets (`APP_PASSWORD`, API keys) + +Never put secrets in the Nix store. Use [agenix](https://github.com/ryantm/agenix) +or [sops-nix](https://github.com/Mic92/sops-nix). Minimal agenix setup: + +```bash +# on your machine, in the secrets repo of the host +npx agenix generate kisassistant-password -a $(readlink -f /etc/ssh/ssh_host_ed25519_key.pub) +# write the admin key into the file, e.g.: +echo "$(openssl rand -base64 24)" | agenix -e kisassistant-password.age # keep a copy! +``` + +Then on the host: + +```nix +age.secrets.kisassistant-password.file = /etc/nixos/secrets/kisassistant-password.age; +``` + +The module consumes it as `passwordFile` and injects it into the service via +systemd `LoadCredential` — the file is read at runtime only. + +## 4. Enable the service + +```nix +services.kisassistant = { + enable = true; + + # REQUIRED — the module refuses to evaluate without it. + package = kisassistant.packages.${pkgs.system}.default; + + # admin key file (agenix/sops-nix path on the host) + passwordFile = config.age.secrets.kisassistant-password.path; + + # storage (created by systemd StateDirectory, owned by the service user) + # SQLite: /var/lib/kisassistant/data + # CDN: /var/lib/kisassistant/cdn + dataDir = "/var/lib/kisassistant"; + + # LLM backends + ollamaHost = "http://10.66.66.4:11434"; + extraEnv = { + MISTRAL_ENABLED = "true"; # non-secret toggles only — see the note below + }; +}; +``` + +The module automatically sets for the systemd unit: + +| Env | Value | +|---|---| +| `NODE_ENV` | `production` | +| `DATA_DIR` | `/var/lib/kisassistant/data` | +| `CDN_DIR` | `/var/lib/kisassistant/cdn` | +| `DRIVER_DEBUG` | `false` | +| `PORT` / `HOST` | `127.0.0.1:3000` (configurable) | + +The module also puts `tesseract` on the service `PATH`, so the OCR platform +tool (`POST /api/tools/ocr`, image attachments) works out of the box. +`OCR_LANG` / `OCR_MAX_CHARS` can be overridden via `extraEnv`. + +> **API driver keys**: `extraEnv` values land in the unit's `Environment=`, +> which is world-readable in the systemd unit file. For driver API keys prefer +> `EnvironmentFile=` via a drop-in (or pass them from agenix with +> `systemd.services.kisassistant.serviceConfig.EnvironmentFile`), e.g. +> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a +> drop-in containing the key. + +## 5. TLS reverse proxy (`enableNginx`) + +The conventional switch is `enableNginx` — a tri-state: + +| Value | Behaviour | +|---|---| +| `true` | nginx vhost + ACME configured (requires `domain`) | +| `false` | nginx never touched, even if `domain` is set | +| `null` (default) | legacy: nginx configured iff `domain` is set | + +```nix +services.kisassistant = { + enableNginx = true; + domain = "chat.kisakay.com"; # vhost name + ACME cert name + # host/port default to 127.0.0.1:3000 +}; + +# Required once per host for ACME (Let's Encrypt ToS): +security.acme.acceptTerms = true; +security.acme.defaults.email = "you@kisakay.com"; + +# Keep the backend loopback-only and let nginx own 80/443: +networking.firewall.allowedTCPPorts = [ 80 443 ]; +``` + +The generated vhost: `forceSSL`, `enableACME`, `recommendedProxySettings`, and +`proxy_buffering off` on `/` so SSE token streaming (`/api/chat`) is never +buffered. + +**Own nginx instead?** Set `enableNginx = false` (or omit `domain`) and write +your own vhost; proxy to `http://127.0.0.1:3000` and remember +`proxy_buffering off;` for `/` (or at least `/api/chat`). + +## 6. First deploy & smoke test + +```bash +sudo nixos-rebuild switch --flake .#myhost +``` + +Check the unit and the proxy: + +```bash +systemctl status kisassistant +journalctl -u kisassistant -f # boot log (APP_PASSWORD errors are fatal) + +curl -I https://chat.kisakay.com/ # 200 via nginx +curl -s https://chat.kisakay.com/api/models -H "Authorization: Bearer $TOKEN" +``` + +Log in through the web UI as `admin` with the `APP_PASSWORD` from your secret +file, then create user accounts from the **Accounts** panel (each gets a +one-time `ka_…` key). See `docs/OPERATIONS.md` for account management. + +If port 3000 is used by something else on the host, change +`services.kisassistant.port` and the proxy target follows automatically. + +## 7. Operations: logs, backups, updates + +**Logs** — per-driver debug lines are off in production +(`DRIVER_DEBUG=false`); flip temporarily with: + +```bash +sudo systemctl edit kisassistant # [Service] Environment=DRIVER_DEBUG=true +sudo systemctl restart kisassistant +``` + +**Backups** — everything is in `/var/lib/kisassistant`: + +```bash +# SQLite is in WAL mode; prefer the backup API over raw copies: +sqlite3 /var/lib/kisassistant/data/kisassistant.db ".backup '/backup/kisassistant.db'" +systemctl stop kisassistant && tar -C /var/lib -czf /backup/kisassistant-$(date +%F).tgz kisassistant && systemctl start kisassistant +``` + +**Updates** — the usual flake flow: + +```bash +nix flake update kisassistant # or update the rev you pin +sudo nixos-rebuild switch --flake .#myhost +sudo nix store gc # keep the previous generation around as needed +``` + +After dependency bumps the first build may fail with a `bunDepsHash` mismatch +— paste the reported hash into `flake.nix` (see section 2). + +## 8. Module option reference + +| Option | Type / default | Description | +|---|---|---| +| `enable` | bool | the switch | +| `package` | null **(required)** | flake package, e.g. `kisassistant.packages.${pkgs.system}.default` | +| `port` | port, `3000` | backend listen port | +| `host` | str, `127.0.0.1` | backend bind address (keep loopback behind nginx) | +| `dataDir` | str, `/var/lib/kisassistant` | state root (`data/` + `cdn/` live inside) | +| `user` | str, `kisassistant` | system user/group for the service | +| `passwordFile` | null \| path | file containing `APP_PASSWORD` (agenix/sops-nix) | +| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint | +| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` | +| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) | +| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 83becca..f02a6d1 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -6,12 +6,22 @@ Copy `.env.example` to `.env`. Full list: | Variable | Default | Purpose | |---|---|---| +| `NODE_ENV` | `development` | `production` under the NixOS module (and CI); gates the `DRIVER_DEBUG` default | | `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) | | `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` | | `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) | | `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) | | `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) | +| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool | +| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) | +| `TESSERACT_BIN` | `tesseract` | path to the tesseract binary (NixOS module puts it on PATH; missing binary cleanly disables OCR with 501) | +| `DRIVER_DEBUG` | `true` dev / `false` prod | per-driver debug logging to stdout | | `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | *(empty)* / `587` / `false` | key-recovery mailer — empty host disables it (`false` = STARTTLS, `true` = implicit TLS/465) | +| `SMTP_USER` / `SMTP_PASS` | *(empty)* | SMTP auth (omit both for open relays / local catchers) | +| `SMTP_FROM` | `KisAssistant ` | sender address | +| `APP_URL` | `http://localhost:3000` | public base URL used in reset links — **must** be the real origin in prod | +| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) | | `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP | | `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver | | `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | @@ -41,8 +51,21 @@ unshare. See `AGENTS.md`. 5. Rotate a compromised key with the regenerate button (old sessions die); delete removes the account with all its chats and shares. -## Local file CDN (avatars) +## Key recovery via email (optional) +Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise +the login page hides the "Forgot your access key?" link. + +1. Admin sets a recovery email at account creation (or the user sets their own + in profile settings). +2. User clicks the link on the login page and enters their username. The API + always answers `{ok:true}` (no account enumeration) and, if the account has + an email, sends a one-time `/reset/` link (60 min, single use). +3. Opening the link shows a confirm page; confirming **rotates the key + immediately** — the new `ka_…` key is displayed once, old sessions die. + (`admin` itself is excluded: its key lives in `.env`.) + +## Local file CDN (avatars) `PUT /cdn//` (Bearer) / `GET /cdn//.` (public), e.g. `/cdn/avatar/.png`. Namespaces live in `CDN_NAMESPACES` (`src/cdn.ts`) — add one to extend (future usage). @@ -55,26 +78,47 @@ unshare. See `AGENTS.md`. - Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public, max-age=3600`. No listing, no traversal (strict charsets + SPA fallback). +## Platform tools & attachments + +Uploads always go through platform tools (`GET /api/tools` lists them with +availability). Today: **OCR** (`POST /api/tools/ocr`, tesseract). + +- Images (jpg/png/webp, 5 MB max, magic-verified, 20 jobs/hour/account) are + transcribed **on the backend to TXT** — the model never receives image + bytes. Text files ride the `text` CDN namespace (UTF-8 validated, 500 KB + max, keys prefixed with the account id). +- The UI (paperclip menu in the composer) explains this and always shows a + **pre-transcription preview**: the user reviews/edits the text, then + attaches it. Attachments travel as labeled text blocks inside the message, + so they persist in history and work with every model. + ## NixOS deployment (`chat.kisakay.com`) -`flake.nix` exposes `packages..default` (backend + prebuilt frontend) -and `nixosModules.default`. The frontend is compiled offline with -`buildNpmPackage` from `frontend/package-lock.json` — commit that file, and on -a hash mismatch paste the hash Nix reports into `npmDepsHash`. +Full step-by-step guide: **[NIXOS-HOSTING.md](./NIXOS-HOSTING.md)**. + +`flake.nix` exposes `packages..default` (backend + prebuilt frontend, +frontend compiled offline from `frontend/bun.lock` via a fixed-output +`bun install`) and `nixosModules.default`. ```nix imports = [ kisassistant.nixosModules.default ]; services.kisassistant = { enable = true; - package = kisassistant.packages.${pkgs.system}.default; - domain = "chat.kisakay.com"; # nginx + ACME + package = kisassistant.packages.${pkgs.system}.default; # required + domain = "chat.kisakay.com"; # legacy switch: implies nginx + ACME + enableNginx = true; # explicit; requires domain passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix ollamaHost = "http://10.66.66.4:11434"; - extraEnv = { MISTRAL_ENABLED = "true"; }; # optional - # secrets for API drivers: pass via environment files / extraEnv, never the store + extraEnv = { MISTRAL_ENABLED = "true"; }; # optional }; +security.acme.acceptTerms = true; # required for the ACME/TLS vhost ``` +The module sets `NODE_ENV=production`, `CDN_DIR=/var/lib/kisassistant/cdn` +and `DRIVER_DEBUG=false` for the systemd service. `enableNginx` is a +tri-state: `true`/`false` force the bundled reverse proxy on/off, `null` +(the default) keeps the legacy behaviour (nginx iff `domain` is set). + Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never baked into the Nix store. The service stores SQLite under `/var/lib/kisassistant/data`; nginx terminates TLS and proxies to diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fc1615..df22315 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import { api, clearToken, getToken } from "./lib/api.ts"; -import type { ChatMessage, Conversation, DriverModel, User } from "./lib/types.ts"; +import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts"; import { Login } from "./components/Login.tsx"; import { Sidebar } from "./components/Sidebar.tsx"; import { Chat } from "./components/Chat.tsx"; -import { ConvEditDialog, SettingsModal, ShareModal } from "./components/dialogs.tsx"; +import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx"; import { AdminPanel } from "./components/AdminPanel.tsx"; import { SharePage } from "./components/SharePage.tsx"; +import { ResetPage } from "./components/ResetPage.tsx"; import { ConfirmDialog } from "./components/ui.tsx"; function shareIdFromPath(): string | null { @@ -16,6 +17,12 @@ function shareIdFromPath(): string | null { // Module-level: the path never changes without a full reload, so hook order stays stable. const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null; +const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null; + +function resetTokenFromPath(): string | null { + const m = window.location.pathname.match(/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/); + return m ? m[1]! : null; +} function applyTheme(theme: string) { const root = document.documentElement; @@ -27,7 +34,7 @@ function applyTheme(theme: string) { export function App() { const [user, setUser] = useState(null); - const [checking, setChecking] = useState(!SHARE_ID && !!getToken()); + const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !!getToken()); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); @@ -42,8 +49,16 @@ export function App() { const [deleteConv, setDeleteConv] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false); + const [attachments, setAttachments] = useState([]); + const [attachError, setAttachError] = useState(null); + const [filePreview, setFilePreview] = useState(null); + const [previewBusy, setPreviewBusy] = useState(false); + const [ocrAvailable, setOcrAvailable] = useState(false); - // Public share route: no auth needed. SHARE_ID is constant for the page lifetime. + // Public routes: no auth needed. IDs are constant for the page lifetime. + if (RESET_TOKEN) { + return ; + } if (SHARE_ID) { return ; } @@ -71,7 +86,12 @@ export function App() { .then(async (res) => { setUser(res.user); applyTheme(res.user.theme); - const [m, c] = await Promise.all([api.models().catch(() => ({ models: [] })), api.convs().catch(() => ({ conversations: [] }))]); + const [m, c, t] = await Promise.all([ + api.models().catch(() => ({ models: [] })), + api.convs().catch(() => ({ conversations: [] })), + api.tools().catch(() => ({ tools: [] as { name: string; available: boolean }[] })), + ]); + setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)); setModels(m.models); if (m.models.length > 0) setModel(m.models[0]!.id); setConvs(c.conversations); @@ -106,6 +126,7 @@ export function App() { setModels(m.models); if (m.models.length > 0) setModel(m.models[0]!.id); }).catch(() => {}); + api.tools().then((t) => setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available))).catch(() => {}); refreshConvs(); } @@ -116,6 +137,8 @@ export function App() { setConvs([]); setMessages([]); setActiveId(null); + setAttachments([]); + setFilePreview(null); } async function newChat() { @@ -140,8 +163,14 @@ export function App() { } } const userMsg: ChatMessage = { role: "user", content: text }; + // Attachments travel as reviewed text blocks appended to the message. + const blocks = attachments.map((a) => + `[attached ${a.kind === "ocr" ? "image transcription" : "text file"}: ${a.name}]\n\`\`\`text\n${a.text}\n\`\`\``, + ); + if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n"); const history = [...messages, userMsg]; setMessages(history); + setAttachments([]); setSending(true); setStreaming(""); let full = ""; @@ -161,8 +190,51 @@ export function App() { } } - async function removeConv(c: Conversation) { + /** Platform-tools upload flow: image -> OCR, text -> CDN, both previewed before attach. */ + async function handlePickFile(kind: "ocr" | "text", file: File) { + if (!user) return; + setAttachError(null); + if (kind === "ocr" && file.size > 5 * 1024 * 1024) { + setAttachError("Image too large (max 5MB)."); + return; + } + if (kind === "text" && file.size > 500 * 1024) { + setAttachError("Text file too large (max 500KB)."); + return; + } + setFilePreview({ name: file.name, kind, text: "", truncated: false }); + setPreviewBusy(true); try { + if (kind === "ocr") { + const r = await api.ocrImage(file); + setFilePreview({ name: file.name, kind, text: r.text, truncated: r.truncated }); + } else { + const key = `${user.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const put = await fetch(`/cdn/text/${key}`, { + method: "PUT", + headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": "text/plain" }, + body: file, + }); + const pdata = (await put.json().catch(() => ({}))) as { error?: string; url?: string }; + if (!put.ok || !pdata.url) throw new Error(pdata.error || `Upload failed (${put.status})`); + const text = await (await fetch(pdata.url)).text(); + setFilePreview({ name: file.name, kind, text, truncated: false }); + } + } catch (e) { + setFilePreview(null); + setAttachError(e instanceof Error ? e.message : "Upload failed"); + } finally { + setPreviewBusy(false); + } + } + + function attachPreviewText(text: string) { + if (!filePreview || !text.trim()) return; + setAttachments((prev) => [...prev, { id: crypto.randomUUID(), name: filePreview.name, kind: filePreview.kind, text }]); + setFilePreview(null); + } + + async function removeConv(c: Conversation) { try { await api.deleteConv(c.id); if (activeId === c.id) { setActiveId(null); @@ -218,6 +290,17 @@ export function App() { sidebarCollapsed={sidebarCollapsed} onExpandSidebar={() => setSidebarCollapsed(false)} onOpenNav={() => setMobileNav(true)} + attachments={attachments} + attachError={attachError} + ocrAvailable={ocrAvailable} + onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((a) => a.id !== id))} + onPickFile={handlePickFile} + /> + { if (!previewBusy) setFilePreview(null); }} + onAttach={attachPreviewText} /> vo // create form const [username, setUsername] = useState(""); const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); async function refresh() { setBusy(true); @@ -43,10 +44,11 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo e.preventDefault(); setError(""); try { - const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined }); + const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined, email: email.trim() || undefined }); setFreshKey({ username: res.user.username, key: res.key }); setUsername(""); setDisplayName(""); + setEmail(""); setShowCreate(false); await refresh(); } catch (err) { @@ -103,6 +105,9 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo
setDisplayName(e.target.value)} placeholder="Alice" />
+
+ setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> +
@@ -148,6 +153,7 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) { const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); + const [email, setEmail] = useState(""); const [theme, setTheme] = useState("auto"); const [error, setError] = useState(""); @@ -155,6 +161,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose if (user) { setDisplayName(user.displayName); setAvatarUrl(user.avatarUrl); + setEmail(user.email); setTheme(user.theme); setError(""); } @@ -164,7 +171,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose e.preventDefault(); if (!user) return; try { - await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), theme }); + await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme }); onSaved(); onClose(); } catch (err) { @@ -177,6 +184,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
setDisplayName(e.target.value)} maxLength={60} required /> setAvatarUrl(e.target.value)} placeholder="https://…" /> + setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> void; onOpenNav: () => void; + attachments: Attachment[]; + attachError: string | null; + ocrAvailable: boolean; + onRemoveAttachment: (id: string) => void; + onPickFile: (kind: "ocr" | "text", file: File) => void; }) { const [draft, setDraft] = useState(""); + const [attachOpen, setAttachOpen] = useState(false); const boxRef = useRef(null); const taRef = useRef(null); + const imgRef = useRef(null); + const txtRef = useRef(null); const modelGroups: PickerGroup[] = (() => { const byDriver = new Map(); @@ -162,7 +175,73 @@ export function Chat({
- +
+ {attachError && ( +

{attachError}

+ )} + {attachments.length > 0 && ( +
+ {attachments.map((a) => ( + + {a.kind === "ocr" ? : } + {a.name} + + + ))} +
+ )} +
+
+ {attachOpen && ( + <> +
setAttachOpen(false)} /> +
+ + +

+ Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images. +

+
+ + )} +
+ + + { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} /> + { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />