KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Architecture (backend)
|
||||
|
||||
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
|
||||
frontend (same origin, no CORS). Persistence is SQLite via Bun's native
|
||||
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode).
|
||||
|
||||
```
|
||||
browser ──► Bun.serve (src/index.ts)
|
||||
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db
|
||||
│ └─────► DriverRegistry ──► ollama / APIs
|
||||
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id)
|
||||
```
|
||||
|
||||
## Request flow: chat
|
||||
|
||||
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
|
||||
|
||||
1. Bearer token → `verifyToken` (sha256 lookup in `sessions`, expiry check).
|
||||
2. `DriverRegistry.splitId` routes to the owning driver (unknown/disabled → 400).
|
||||
3. If `conversationId` is set: conversation must belong to the caller, the new
|
||||
user message is stored (deduped against the last stored message), new chats
|
||||
are auto-titled from it.
|
||||
4. Non-streaming → `driver.chat()` → JSON. Streaming → `driver.chatStream()`
|
||||
→ SSE events `token` / `done` / `error`. The full reply is persisted after a
|
||||
successful stream (partial text + `[interrupted]` marker on failure).
|
||||
|
||||
## Database schema
|
||||
|
||||
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`). |
|
||||
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
|
||||
| `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`. |
|
||||
|
||||
User deletion cascades manually in `deleteUser()` (messages → shares →
|
||||
conversations → sessions → user) inside a transaction.
|
||||
|
||||
## Auth
|
||||
|
||||
- `POST /api/auth/login { username, key }`, sliding-window rate limit per IP
|
||||
(`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN`, default 5 per 10 min) + 400 ms
|
||||
delay on failure. Success clears the IP counter and issues a fresh token.
|
||||
- `admin` is authenticated against `APP_PASSWORD` (timing-safe); users against
|
||||
their `ka_…` access key (timing-safe against the stored hash).
|
||||
- Keys are generated with `crypto.randomBytes` (`ka_` + 24 bytes base64url) and
|
||||
**returned once** at creation/regeneration. Regeneration also wipes the
|
||||
account's sessions.
|
||||
|
||||
## Drivers
|
||||
|
||||
`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||
`DriverRegistry` with global priority
|
||||
`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`.
|
||||
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||
|
||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||
`POST /api/chat` with NDJSON streaming.
|
||||
- `ApiDriverBase`: shared OpenAI-compatible `/models` + `/chat/completions`
|
||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
||||
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
|
||||
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
||||
- `PuppeteerOpenAIDriver`: intentional stub, always disabled (headless scraping
|
||||
of chatgpt.com is fragile and against ToS).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Frontend
|
||||
|
||||
React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/remark-gfm, Inter
|
||||
Variable self-hosted (`@fontsource-variable/inter` — no external requests).
|
||||
Built with `bun run build:frontend` into `public/` (Vite `emptyOutDir`; that
|
||||
directory is a build artifact, never hand-edit, not committed).
|
||||
|
||||
## Routing
|
||||
|
||||
No router library — `App.tsx` matches `window.location.pathname` once (module
|
||||
level, constant for the page lifetime): `/share/:publicId` renders the public
|
||||
read-only `SharePage` (no auth), everything else renders the authenticated app.
|
||||
|
||||
## State & data flow
|
||||
|
||||
- `App.tsx` owns: `user`, `models`/`model`, `convs`, `activeId`, `messages`,
|
||||
`streaming`, modal states. All server state is re-fetched (no client cache
|
||||
library); conversations themselves live server-side in SQLite.
|
||||
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
|
||||
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
||||
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
||||
- Sending: ensure a server conversation exists (created lazily on first send),
|
||||
optimistic user message, stream tokens into `streaming`, then append the full
|
||||
assistant message and refresh the list (server auto-titles new chats).
|
||||
|
||||
## Components (`components/`)
|
||||
|
||||
- `ui.tsx` — design system. `Button` (primary/secondary/ghost/danger),
|
||||
`IconButton`, `Input`, `Field`, `Modal`, `ConfirmDialog`, `ContextMenu`,
|
||||
`Avatar` (remote URL or gradient initials), `CopyButton`, `Spinner` and
|
||||
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
|
||||
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
|
||||
**Never use a native `<select>`**; use `Picker` for model and theme choices.
|
||||
- `Login.tsx` — username + access-key card.
|
||||
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
|
||||
(right-click or `···`: Rename / Set topic / Share publicly / Delete), user
|
||||
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).
|
||||
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
||||
copy, unshare), `SettingsModal` (display name, avatar URL, theme).
|
||||
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
|
||||
regenerate (revokes sessions), edit, delete with confirm.
|
||||
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated.
|
||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
|
||||
a copy button.
|
||||
|
||||
## Theming
|
||||
|
||||
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
|
||||
class from the account theme (`auto` follows `prefers-color-scheme` with a
|
||||
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
|
||||
everywhere; every surface needs a `dark:` variant. UI text is English only.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Operations
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Full list:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `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` |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # backend watch mode (restarts on edit), :3000
|
||||
bun run dev:frontend # Vite HMR + /api proxy, :5173 (use this for UI work)
|
||||
bun run dev:all # both at once
|
||||
bun run build:frontend # production build into public/
|
||||
```
|
||||
|
||||
Smoke tests: start the backend on a **free non-3000 port** with a throwaway
|
||||
`DATA_DIR` (port 3000 is the owner's dev server — do not touch it), then drive
|
||||
the API with curl/python: login → models → conversations → chat → share →
|
||||
unshare. See `AGENTS.md`.
|
||||
|
||||
## Managing accounts
|
||||
|
||||
1. Log in as `admin` with the `APP_PASSWORD` key.
|
||||
2. Open **Accounts** → *New account* → username (+ display name).
|
||||
3. Copy the one-time `ka_…` key and hand it to the owner (chat, QR code, …).
|
||||
4. Users set their display name, avatar URL (upload e.g. to `catbox.moe`, paste
|
||||
the direct link) and theme in the profile settings.
|
||||
5. Rotate a compromised key with the regenerate button (old sessions die);
|
||||
delete removes the account with all its chats and shares.
|
||||
|
||||
## NixOS deployment (`chat.kisakay.com`)
|
||||
|
||||
`flake.nix` exposes `packages.<system>.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`.
|
||||
|
||||
```nix
|
||||
imports = [ kisassistant.nixosModules.default ];
|
||||
services.kisassistant = {
|
||||
enable = true;
|
||||
package = kisassistant.packages.${pkgs.system}.default;
|
||||
domain = "chat.kisakay.com"; # nginx + ACME
|
||||
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
|
||||
};
|
||||
```
|
||||
|
||||
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
|
||||
`127.0.0.1:3000`.
|
||||
Reference in New Issue
Block a user