Compare commits

..
64 Commits
Author SHA1 Message Date
k c639f43891 Update index.ts 2026-09-21 22:28:31 +02:00
k 3571c99416 :) 2026-09-21 22:28:29 +02:00
k 0ca3e9d4ca own provider update + retry buggy response 2026-09-21 22:26:32 +02:00
k 0943cdf5f3 fixes 2026-09-21 22:17:05 +02:00
k 19b791493f :3 2026-09-21 22:12:37 +02:00
k b4cb62b490 ;p 2026-09-21 22:04:07 +02:00
k 59fc78332d Update App.tsx 2026-09-21 22:00:57 +02:00
k 5c616a9d03 Update App.tsx 2026-09-21 22:00:24 +02:00
k a31fc02ef6 Update OPERATIONS.md 2026-09-21 21:59:00 +02:00
k 836995824a Update OPERATIONS.md 2026-09-21 21:57:38 +02:00
k 43003957eb :3 2026-09-21 21:57:36 +02:00
k e69654430a . 2026-09-21 21:54:03 +02:00
k aa33828215 report system 2026-09-21 21:51:38 +02:00
k 4cefd3e17f :3 2026-09-21 21:10:09 +02:00
k c0e7899ab3 :3 2026-09-21 20:17:10 +02:00
k 62370e61bf Update App.tsx 2026-09-21 19:25:51 +02:00
k 5903b77d95 / 2026-09-21 19:14:55 +02:00
k 2a34d16b52 :) 2026-09-21 18:15:13 +02:00
k 670d7f1dd1 Update i18n.ts 2026-09-21 17:57:36 +02:00
k 6d2d9b897e . 2026-09-21 17:20:08 +02:00
k 2705dd47b3 . 2026-09-21 17:14:32 +02:00
k acdfe4ca8e o 2026-09-21 17:09:05 +02:00
k 908c673c58 / 2026-09-21 17:08:07 +02:00
k 2e6eb07301 . 2026-09-21 17:06:20 +02:00
k 66450303a1 translations :3 2026-09-21 16:53:15 +02:00
k af9b8c17d0 Update AdminPanel.tsx 2026-09-21 16:49:55 +02:00
k 245d26677b , 2026-09-21 16:48:59 +02:00
k 70c21698e8 . 2026-09-21 16:38:40 +02:00
k 8038e02303 . 2026-09-21 16:37:40 +02:00
k ad1629164f Update index.ts 2026-09-21 16:35:49 +02:00
k 8f49bb56a2 fixing translations 2026-09-21 16:35:46 +02:00
k efeb11577d . 2026-09-21 16:34:14 +02:00
k 48fb137c13 fixes 2026-09-21 16:31:55 +02:00
k b357d89d81 . 2026-09-21 16:16:47 +02:00
k f46a6b4412 update 2026-09-21 16:12:51 +02:00
k 1352f5374e Update i18n.ts 2026-09-21 16:02:38 +02:00
k d3780edc9c fixes 2026-09-21 16:02:05 +02:00
k 133a08b7ed update 2026-09-21 15:57:10 +02:00
k 2dfcb944d1 . 2026-09-21 15:47:33 +02:00
k 47f78133c4 Update SharePage.tsx 2026-09-21 15:40:24 +02:00
k 8c8a89ab30 fixes 2026-09-21 15:39:30 +02:00
k a9578438b0 Update another_prompt 2026-09-21 15:32:47 +02:00
k 3a65f1ba69 Update SharePage.tsx 2026-09-21 15:29:28 +02:00
k 19a47372c6 Update Login.tsx 2026-09-21 15:27:03 +02:00
k 9c3994241a fix logos brand 2026-09-21 15:24:44 +02:00
k 8cb307c9a1 . 2026-09-21 15:19:40 +02:00
k 20fab17880 Update AdminCenter.tsx 2026-09-21 15:15:57 +02:00
k a196e25e83 Update dialogs.tsx 2026-09-21 15:12:14 +02:00
k bec38d9f34 updates 2026-09-21 15:08:59 +02:00
k 1de54b3caf . 2026-09-21 15:02:41 +02:00
k c18397880a updates 2026-09-21 14:38:00 +02:00
k 5f5f72268a fixes 2026-09-21 14:30:28 +02:00
k c05aa7a211 update 2026-09-21 13:36:25 +02:00
k 59402b250c update 2026-09-21 13:05:25 +02:00
k 6cb9cba384 update 2026-09-21 12:56:11 +02:00
k a5da592fcf Update config.ts 2026-09-21 12:09:30 +02:00
k 330fffc700 new update 2026-09-21 12:08:22 +02:00
k 06447062be updates 2026-09-21 12:01:35 +02:00
k 0e44bf6364 fixes 2026-09-21 11:47:59 +02:00
k db14c9bf21 . 2026-09-21 11:41:51 +02:00
k d05dad2e48 wiki update 2026-09-21 11:38:19 +02:00
k 56628fa103 Update ui.tsx 2026-09-21 11:29:05 +02:00
k d37f4efd69 lil update bro 2026-09-21 11:24:16 +02:00
k 739c14324a KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module 2026-09-21 11:00:32 +02:00
82 changed files with 21266 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# --- 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
# other No-KYC accounts from the web UI (Accounts panel).
# Generate: openssl rand -base64 24
APP_PASSWORD=change-me
# Session TTL in hours (Bearer token stored in localStorage, no cookies)
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 <[email protected]>
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
# --- drivers ---
# Priority (global): ollama -> mistral -> glm -> arcaic -> deepseek -> anthropic -> openai
OLLAMA_HOST=http://10.66.66.4:11434
OLLAMA_ENABLED=true
# Stubs below: leave empty to keep disabled. Implemented last per spec.
MISTRAL_API_KEY=
MISTRAL_ENABLED=false
# GLM (Zhipu AI, OpenAI-compatible). Key from https://z.ai ; mainland China
# users point GLM_BASE_URL at https://open.bigmodel.cn/api/paas/v4 instead.
GLM_API_KEY=
GLM_ENABLED=false
# GLM_BASE_URL=https://api.z.ai/api/paas/v4
DEEPSEEK_API_KEY=
DEEPSEEK_ENABLED=false
ANTHROPIC_API_KEY=
ANTHROPIC_ENABLED=false
OPENAI_API_KEY=
OPENAI_ENABLED=false
# Gemini (Google AI, OpenAI-compatible endpoint). Key from https://aistudio.google.com/apikey
GEMINI_API_KEY=
GEMINI_ENABLED=false
# Arcaic backend (headless browser, throwaway profile per process).
# Every process launch creates a throwaway Firefox profile (deleted on exit).
ARCAIC_ENABLED=false
ARCAIC_EXECUTABLE=firefox
# Headless by default; set to false to watch the window (debugging, or if a
# manual login is ever required).
ARCAIC_HEADLESS=true
ARCAIC_LOGIN_TIMEOUT_S=300
ARCAIC_RESPONSE_TIMEOUT_S=300
+11
View File
@@ -0,0 +1,11 @@
/data/
/cdn/
node_modules/
dist/
/public/
.env
*.log
*.db
*.db-journal
*.db-wal
*.db-shm
+54
View File
@@ -0,0 +1,54 @@
# AGENTS.md — instructions for coding agents working on KisAssistant
Read this before touching the code. Docs live in `docs/` (English).
## What this is
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
frontend, SQLite persistence via Bun's native `bun:sqlite` driver.
## Commands (run from repo root unless noted)
- `bun install` — backend deps (almost none; `@types/bun` only)
- `bun run dev` — backend with **watch mode** (auto-restart on edit), http://localhost:3000
- `bun run dev:frontend` — Vite dev server with HMR + `/api` proxy, http://localhost:5173
- `bun run dev:all` — both at once (two terminals also works)
- `bun run build:frontend` / `bun --cwd frontend build` — typecheck + Vite build into `public/` (wipes it first)
- `bun run typecheck` / `bun run typecheck:frontend` — `tsc --noEmit` for each side
- API smoke tests: boot on a **non-3000 port** with throwaway `DATA_DIR`, drive with curl/python (see `docs/OPERATIONS.md`). Port 3000 is the owner's dev server — **never kill it, never bind it in tests**.
## Layout
```
src/index.ts HTTP routes (Bun.serve). Auth gate: everything under /api/ except /api/auth/login and /api/share/:id
src/config.ts env parsing (PORT, HOST, APP_PASSWORD=admin key, OLLAMA_HOST, DATA_DIR, rate limits)
src/db.ts SQLite store: users, sessions, conversations, messages, shares (+ admin row bootstrap)
src/auth.ts login(username,key), hashed Bearer sessions, sliding-window login rate limit
src/static.ts static file server + SPA fallback (covers /share/:id)
src/drivers/ LLMDriver interface, OllamaDriver, ApiDriverBase, Mistral/DeepSeek/Anthropic/OpenAI, arcaic browser sub-drivers (arcaic-openai/gemini/qwen), DriverRegistry
frontend/src/ App.tsx (routing+state), lib/api.ts (fetch wrapper), components/
public/ BUILD OUTPUT (Vite). Never hand-edit; rebuild instead. Not committed (see .gitignore)
flake.nix/module.nix NixOS packaging + service module
```
## Auth model (do not weaken)
- No cookies. Bearer tokens in `localStorage`, stored **sha256-hashed** in SQLite.
- `admin` logs in with `APP_PASSWORD` from `.env`. Users log in with admin-issued `ka_…` keys (also hashed). Comparisons are timing-safe.
- Conversations/shares are per-account; always enforce `conv.user_id === user.id`. Admin has no cross-account read.
- Never log, print, or commit secrets. `.env`, `data/`, `*.db` are gitignored.
## Conventions
- Language: TypeScript strict. UI text: English only.
- Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN).
- Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework).
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
## NixOS
`flake.nix` builds the frontend offline via `buildNpmPackage` (needs `frontend/package-lock.json` committed). If `npmDepsHash` mismatches, take the hash from the error. Keep `module.nix` options backward compatible.
+151
View File
@@ -0,0 +1,151 @@
# KisAssistant (kisa's assistant)
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
Signal-style, Inter font, light/dark/auto themes), Bun backend with
class-oriented LLM **drivers** and a **SQLite** database (Bun native driver) for
accounts, server-side chats and public shares. No cookies — Bearer keys in
`localStorage`.
Driver priority:
```
1. ollama → 2. mistral API → 3. glm API → 4. arcaic ×3 (arcaic-openai → arcaic-gemini → arcaic-qwen)
→ 5. deepseek → 6. anthropic → 7. openai
```
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/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md) (server hosting tutorial).
Agent instructions: [`AGENTS.md`](AGENTS.md).
## Quick start
Requirements: [Bun](https://bun.sh) ≥ 1.1, Node 20+ (frontend build), Ollama
reachable at `OLLAMA_HOST` (default `http://10.66.66.4:11434`).
```bash
cp .env.example .env
# edit .env → set APP_PASSWORD (openssl rand -base64 24), check OLLAMA_HOST
bun install
bun run --cwd frontend build # builds React app into public/
bun run dev # http://localhost:3000 (or `bun run start`)
# frontend dev with proxy: bun run --cwd frontend dev # http://localhost:5173
```
Log in as `admin` with the `APP_PASSWORD` key, then create No-KYC user accounts
from the **Accounts** panel (each gets a one-time access key to hand over).
## Accounts (No-KYC, admin-managed)
- `admin` authenticates with `APP_PASSWORD` from `.env`.
- Admin creates users (`username`, display name, avatar URL, theme) — the API
returns a `ka_…` access key **once**; keys are stored hashed (sha256).
- Users log in with `{ username, key }`. Regenerating a key revokes old sessions.
- Per-account: display name, avatar (uploaded to the built-in CDN from profile
settings), theme (`auto`/`light`/`dark`), accent color, feature flags.
## API
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | `/api/auth/login` | rate-limited (5/10min/IP) | `{username, key}` → `{token, expiresAt, user}` |
| POST | `/api/auth/register` | public, rate-limited | self-registration (403 when disabled by admin) → `{user, key}` |
| GET | `/api/auth/verify` | Bearer | → `{ok, user}` |
| POST | `/api/auth/logout` | Bearer | revokes token |
| GET/PATCH | `/api/me` | Bearer | profile: displayName, avatarUrl, theme |
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
| POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) |
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model |
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
| WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) |
| WS | `/api/admin/ws?token=…` | admin | firehose of all access events (badge + triage list) |
| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply |
| POST | `/api/reports` | Bearer | flag an AI response (copyright/gore/falseinfo/bug) → `{report}` (201) |
| GET/PATCH | `/api/admin/reports`, `/api/admin/reports/:id` | admin | report triage: list, reviewing/resolved/dismissed + note |
| GET | `/api/admin/mail` | admin | SMTP credentials viewer (password blurred by default in UI) |
| POST | `/api/admin/mail/verify`, `/api/admin/mail/test` | admin | verify SMTP connectivity / send test mail `{to}` |
| POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account |
| GET/POST/DELETE | `/api/me/totp`, `/api/me/totp/setup`, `/api/me/totp/verify` | Bearer | TOTP two-factor status/setup/verify/disable |
| POST | `/api/auth/totp` | challenge | second login step `{totpToken, code}` → session |
| GET/PATCH/DELETE | `/api/conversations/:id` | owner | incl. messages on GET |
| POST/GET/DELETE | `/api/conversations/:id/share` | owner | public link `/share/:publicId` |
| GET | `/api/conversations/archived` | Bearer | archived chats (hidden from sidebar + search) |
| POST | `/api/conversations/:id/archive`, `/api/conversations/:id/unarchive` | owner | archive (read-only) / restore to the list |
| GET | `/api/share/:publicId` | **public** | read-only shared chat JSON |
| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers + your own-key providers |
| GET/PUT/DELETE | `/api/me/providers`, `/api/me/providers/:id` | Bearer | personal providers (BYOK: openai/anthropic/deepseek/gemini) — keys stored server-side, never returned |
| GET/PATCH | `/api/admin/model-policy` | admin | per-model kill-switch + hourly/daily limits (users filtered, enforced in chat) |
| POST | `/api/chat` | Bearer | `{model, messages, conversationId?, stream?}` → JSON or SSE |
`POST /api/chat` with `conversationId` persists the user message + assistant
reply into that conversation (ownership enforced) and auto-titles new chats.
## Database (SQLite, Bun native)
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username,
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
`resets` (recovery tokens), `settings` (feature flags),
`user_provider_keys` (per-user BYOK API keys: user_id, provider, api_key)
(conv ↔ public id). No migration system — schema is created idempotently at boot.
## Drivers (backend layout)
```
src/drivers/types.ts LLMDriver interface + DriverModel + errors
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI / Gemini drivers (global key or per-user override)
src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini) — throwaway Firefox profile
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory
src/db.ts SQLite store (users, sessions, convs, messages, shares)
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
```
Enable an API driver later: `MISTRAL_ENABLED=true` + `MISTRAL_API_KEY=…`, restart.
## NixOS hosting (chat.kisakay.com)
Full tutorial: **[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md)**.
Flake exposes `packages.<system>.default` (backend + prebuilt frontend) and
`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
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
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` with
`NODE_ENV=production`. SQLite lives in `/var/lib/kisassistant/data`, avatars
(file CDN) in `/var/lib/kisassistant/cdn`.
## Frontend
`frontend/` — React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/gfm,
Inter Variable (self-hosted, no external requests). `bun run --cwd frontend build`
outputs to `public/` (wiped first). Collapsible sidebar, right-click context menu
(rename / topic / share / delete), share modals, confirm dialogs, admin accounts
panel, profile settings, public read-only `/share/:id` page. English only.
+21
View File
@@ -0,0 +1,21 @@
--- VERY HIGH PRIORITY:
Update pfp composer when new upload.actually i have to refresh the page after the save button for seeing it
when upload did we have to refresh the pfp preview composer to the new uploaded pictures.
--- MEDIUM PRIORITY
Being abble to :
Disable features / Enable it:
ie : thinking, deep search, etc...
Downloading models throught a catalog : ollama only
--- LOW PRIORITY
Search into the old prompts with a search bar.
integrate the platform name in the top of the conversations slide list. with a well beautiful font and a flower in lucide svg icon for the brand icon
Make a beautiful SEO integrate into the html file for disovering.
make a robots.txt file for let all llm/crawler/scrapper visiting the site.
+60
View File
@@ -0,0 +1,60 @@
--- VERY HIGH PRIORITY
fait en sorte de pouvoir upload un avatar (Servir un cdn en local via fichier upload sur le serveur dans un dossier à côté de la codebase)
cdn comme sa
http<remote>/cdn/<avatar>/<id of the account>.<extension>
pouvoir gérer dans le code proprement les routes cdn . FU (futur usage/futurproof)
http<?s>://<root host>/cdn/<x>/<key>.<fileTypeAllowed>
Extension authorisé ONLY:
jpg/jpeg/webp/png
BLACKLISTE SYSTÈMATHIQUEMENT LE RESTE.
Pas que un detect typeFile avec l'extension.
Logique propre de détection du fichier *magic byte, looking bytes, etc*
Changer l 'avatar à plusieurs règles:
Rate-limit per change / per account de:
5 fois maximum en 2 heures
(window time: 2 hours, max count 5)
file length maxi: 5MB
SYSTEM DE CHANGEMENT DE MDP:
Donc avoir une upload box, propre, adapative etc
---- MEDIUM PRIORITY
ADAPTATION MOBILE:
GÉRER LES MODALS, LES BOXES, LES BOARDING, LES CHAT BOXES AUSSI POUR PETIT ÉCRAN, ADAPTER LEUR WEBVIEW PORTAGE, DIALOG BOX, CONFIRMATON, TRAY CHANNELS SIDE LIST...
DRAG AND SNAP LIKE PHONES, SCROLLING FOR OPENGING/EXITING ETC..
--- LOW PRIORITY
Système de service mails aussi pour les comptes (optionel) pour recovery passwords, dans le page de login:
- Recover password with emails. smtp server,
--- NEW NEW BUG:
when uploading new pfp, actually it uploading the file without click needing into save button.
the normal flow that i want is. uploading , updating the preview, cancel button: keep the current one, re-freshing the previw.
save -> saving and pushing the pfp to the api
--- bugs:
when disabling attachment sendability: the text "Message" placeholder nto the composer chat are too tigh to the left border, make a fallback padding when no attachment button
make in the settigns: an sub category: changing key, admiting TOTP key/passkey for security
--- bugd II:
sur la page share, l' avatar de l'utilisateur ne s'affiche pas -> fix sa
--- improvements:
éviter de ask h24 ollama pour ces modèles quand un utilisateur ce connece. c'est le backend qui sert les demande d el'utilisateurs. iL relay pas sur le ollama. Le backend vas cacher avec un TLL les modèles présent en mémire dans un objet qui est dans le backend. refresh avec un TT: toutes les minutes.
L'administrateur lui, qand il demande les modèles dams le ollama, lui, force un refresh .
--- ADMIN CENTER: SMTP CONNECTIVITY.
Make a SMTP tester in the admin center, and also a viewer of the smtp credentials (default bluring the smtp pasword OBVIOUSLY) And also a SMTP tester , sending a mail to a provided one , to tets the SMTP connectivity
--- ARCHIVED FEATURES:
a way to archive conversaitons with the contetxual menue.
When conversations are archived. it's impossible to write in . only read-only. also, when conversations are archived, they are not anymore visible in the conversations side list. They are still unarchivable and checkable, in a new sub-category options into the settigns modal : "Archiveds Chats"
+56
View File
@@ -0,0 +1,56 @@
---- HIGH PRIORITY
Loading screen with animation instead of just : "Loading..."
Like a flower that "loading" like circle animation, or beautiful disapearing/appearing animation
or even a beautiful flower in svgs that got built block by block
or something original but in the actual theme and Adapative to phone
--- Settings modal.
make a better settings overview,with larger modal in screen . with categories, section for searching
--- UPLOAD URL FOR PFP MUST BE A READ ONLY IN USER . NOW CAUSE OF THE OWN CDN. USER SHOULDNT SEE THAT VALUE /EDIT IT ANYMORe.
--- UPDATE ABOUT REGISTRATION
Another feature for rehistration.:
A WishList. Like you reserv username, mail (needed)
in AdminCenter, a specific category for wish access : accepting/refusing/review a request.
Can refusing with a reason / accepting with reason / reviewing :
- make a /review page
the user want to being granted access can arguing about why him
- admin can see messages, responding, like a minimal ticket syste,
and so accepting reusing.
- the user is who want to earn access receive emails about the current status of the situation
Refusing -with reasons etc
Accepting - with/without reasons etc
mail or reviewing
each new message in the conversations makes a new mail forwarded by (like real ticket)
precise that theses mail arent the official way and need to be responded into the official site (chat.kisakay.com/review)
makes beautiful simple easy - by design HTML for each type of mail
--- UX bugs / improvements:
class="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800"
the actual on:hover makes the settings button very next to the border hover clor
Use the new flower svgs built-in for the ai logo. Only one declaration of the logo, not copy-paste everywhere needing it. One constant and imported anywhere
Make the loading screen smoother
--- SETTINGS REWAMPING:
Make a paginations system for users into the Admin Center.
Filtering, by account timetamp, alphabetily filtered, unverified/etc...
---(NEW) BUGS WITH SETTINGS:
glossian blur its diffent at each settings section opened: need to unify it
padding for buttons into admin center at pagination is so tigh. fix it
--- USERLIST:
pouvoir
--- UX Features to add:
Adding sub category into the settings modal: Languages.
Translate every words/phrase/etc in at least:
Spanish, French, English, Russian, Italian, Portugeese
+80
View File
@@ -0,0 +1,80 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant",
"dependencies": {
"nodemailer": "^10.0.10",
"puppeteer-core": "^25.11.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/nodemailer": "^8.0.2",
},
},
},
"packages": {
"@puppeteer/browsers": ["@puppeteer/[email protected]", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/nodemailer": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"chromium-bidi": ["[email protected]", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
"cliui": ["[email protected]", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"devtools-protocol": ["[email protected]", "", {}, "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA=="],
"emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
"mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"modern-tar": ["[email protected]", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="],
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
"puppeteer-core": ["[email protected]", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw=="],
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typed-query-selector": ["[email protected]", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"webdriver-bidi-protocol": ["[email protected]", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="],
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["[email protected]", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="],
"yargs-parser": ["[email protected]", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"cliui/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}
+141
View File
@@ -0,0 +1,141 @@
# 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; on error the partial text is kept as-is (no internal
markers). Server logs each stream open/done/abort per driver when
`DRIVER_DEBUG=true`. `POST /api/chat` with an archived `conversationId`
is rejected (403) — archived chats are read-only.
Streaming guarantees (ChatGPT-style, no buffering):
- Tokens are forwarded via `controller.enqueue` the moment they arrive from the
LLM; disconnects are detected (`req.signal`, guarded `enqueue`,
stream `cancel()`) and propagated upstream via an `AbortController` passed as
`opts.signal`, so the model stops generating when the tab closes.
- `Bun.serve` runs with `idleTimeout: 0` — the default 10 s would kill slow
streams (cold model load, thinking pauses) mid-generation.
- Responses carry `X-Accel-Buffering: no` and the NixOS nginx vhost sets
`proxy_buffering off`, otherwise the proxy would buffer the whole reply.
## 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, 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). |
| `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). |
| `conversations` | `id, user_id, title, topic, model, archived_at, created_at, updated_at`. `archived_at = 0` means live; otherwise a unix-ms archive timestamp (added idempotently via `ALTER TABLE`, like `users.email`). |
| `user_provider_keys` | `user_id, provider PK, api_key, updated_at`. Personal LLM keys (BYOK) — stored reversibly (needed upstream), never returned to clients (GET exposes presence + last4 only). Wiped with the account in `deleteUser()`. |
| `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.
- `POST /api/auth/register { username, displayName?, email? }` — public
self-registration, guarded by the `registration_enabled` setting (403 when
off), same rate limit and validation as admin creation. Returns `{user, key}`
with the key shown once.
- `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.
## Local file CDN
`src/cdn.ts` + routes in `index.ts`. Generic namespaced layout
(`PUT /cdn/<ns>/<key>` authed, `GET /cdn/<ns>/<key>.<ext>` public) so future
file kinds plug into `CDN_NAMESPACES` without new routes. Today only `avatar`
exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
- Type safety by **magic bytes** (JPEG `FF D8 FF`, PNG signature, `RIFF…WEBP`);
only `jpg`/`png`/`webp` are representable, the stored extension is always
detected, everything else is `415`. Route regex charsets make traversal
unmatchable (attempts fall through to the SPA fallback).
- Per-namespace limits: `avatar` = 5 MB max (`413`), 5 uploads / 2 h /
account (`429` + `Retry-After`), counted on success. Re-upload replaces all
stored variants for the key.
- 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 → glm → gemini → arcaic-openai → arcaic-gemini → arcaic-qwen → 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.
- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
(`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
Concurrent misses share one in-flight upstream fetch. Admins pass
`?refresh=1` to force a fresh fetch (used by the "Ollama models" view);
the flag is ignored for non-admins. Admin pull/delete invalidate the cache.
- `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.
- Personal providers (BYOK, `src/userProviders.ts`): users attach their own
`openai` / `anthropic` / `deepseek` / `gemini` keys in settings
(`GET/PUT/DELETE /api/me/providers`, keys stored in `user_provider_keys`,
never returned — GET exposes presence + last4 only). `GET /api/models`
merges the caller's own-key models into their menu, and `POST /api/chat`
prefers the personal key for those four providers (also when the global
driver is disabled). The admin model policy still applies. Driver
constructors accept an `apiKeyOverride` for these per-user instances.
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
under /tmp, deleted on close). Each sub-driver owns its engine (own
browser + profile) and declares a site config — composer selectors, submit
mode (send-button click vs Enter key), completion signal (newly **visible**
copy control, or `aria-busy="false"` on the response container for Gemini),
answer-text selectors. Anonymous sessions work without login; if the
composer is missing the engine waits up to `ARCAIC_LOGIN_TIMEOUT_S` for a
manual login in the window. Multi-line prompts are typed with Shift+Enter.
Off unless `ARCAIC_ENABLED=true`. Headless by default
(`ARCAIC_HEADLESS=false` for a visible debug window: `bun
scripts/poc-chatgpt.ts --headed`); on NixOS use
`services.kisassistant.enableBrowserDriver` for a headless Firefox in the
service, or wrap it all in a declarative `containers.*` (see
`docs/NIXOS-HOSTING.md`). Smoke test: `bun scripts/poc-chatgpt.ts
--site openai|qwen|gemini` (`--probe` for a state report).
+94
View File
@@ -0,0 +1,94 @@
# 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 — a tiny history wrapper (`lib/route.ts`: `navigate()`,
`useRoute()`, trailing-slash normalization) drives pathname routing:
- `/login` — sign-in card (username + access key + TOTP step). Renders
instantly, **never shows the boot splash**.
- `/register` — full-page self-registration (`RegisterPage.tsx`; key shown
once, then hands username+key to `/login` via `sessionStorage`). No splash.
- `/chat` — the platform itself (auth required; boots behind the splash).
- `/` — redirects to `/chat` when logged in, `/login` otherwise (placeholder
for a future landing page).
- Public routes (unchanged): `/share/:publicId`, `/reset/:token`,
`/review/:id`, `/admin`.
## 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`.
- Boot shows `LoadingScreen` for at least 1.5 s (`MIN_SPLASH_MS` in `App.tsx`)
so the bloom animation always plays, even when the session restores
instantly — **only on `/` and `/chat`**. `/login` and `/register` skip it.
- 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 (`/login`); the Register button
navigates to `/register`, and a fresh registration prefills the form.
- `RegisterPage.tsx` — full-page registration (`/register`) reusing the login
card style; shows the new key once with a copy button, then continues to
`/login`.
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
(right-click or `···`: Rename / Set topic / Share publicly / Archive /
Delete), user chip (opens settings), Accounts button (admin), logout.
Archived chats leave the list (and search); they live in settings.
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
share button), message list (`Markdown` for assistant, emerald bubble for
user), rounded composer with paperclip attach menu, attachment chips
(Enter to send, Shift+Enter for newline). With `readOnly` (archived chat)
the composer and share button are replaced by an unarchive banner.
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
copy, unshare), `SettingsModal` (display name, avatar upload box with
drag & drop, avatar URL, theme, plus an "Archived chats" category to
view / unarchive / delete archived conversations).
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
regenerate (revokes sessions), edit, delete with confirm, plus server-side
search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination.
Renders bare (no modal shell) when embedded in the Admin Center page.
- `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise):
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
`/api/admin/settings`), SMTP viewer (password blurred by default, eye toggle
to reveal) + connectivity tester (verify + send test mail). Follows the platform theme.
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated
(`{ title, topic, model, authorName, authorAvatarUrl, archived, sharedAt, messages }`);
user messages render the author's avatar via `Avatar` (URL or initials);
an "Archived" pill shows when the source conversation is archived.
- `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.
## Mobile
Below the `md` breakpoint the sidebar becomes a slide-in drawer (backdrop tap
or swipe-left closes it, hamburger in the chat header opens it). Modals render
as bottom sheets with a drag handle — pull down past the threshold to dismiss
(tap backdrop or Escape works too). Composer respects
`env(safe-area-inset-bottom)`. No separate mobile codebase: same components,
responsive Tailwind classes + minimal touch handlers.
+294
View File
@@ -0,0 +1,294 @@
# 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.
### Arcaic backend (`enableBrowserDriver`)
The `arcaic-*` sub-drivers (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`)
answer through web-UI sessions: a headless Firefox from the nix store runs
**inside the service** and drives the sites via a browser-automation session
(WebDriver BiDi). Anonymous sessions work — no login needed — and every
browser launch gets a throwaway profile (mkdtemp under `/tmp`, deleted on
exit).
```nix
services.kisassistant = {
enable = true;
# …
enableBrowserDriver = true; # adds firefox to the service PATH and sets
}; # ARCAIC_ENABLED/HEADLESS/EXECUTABLE
```
Overridable through `extraEnv` (values win over the module defaults):
`ARCAIC_LOGIN_TIMEOUT_S` (default 300), `ARCAIC_RESPONSE_TIMEOUT_S`
(default 300). The browser only starts on the **first request** that uses one
of the `arcaic-*:chat` models (`arcaic-openai`, `arcaic-gemini`,
`arcaic-qwen`) — boot cost stays zero otherwise.
**Full isolation: run the whole app in a declarative NixOS container.**
The driver launches Firefox as a subprocess, so the browser must live in the
same namespace as the backend — the cleanest isolation is an nspawn
container wrapping the entire service:
```nix
containers.kisassistant = {
autoStart = true;
privateNetwork = true;
hostAddress = "10.99.0.1";
localAddress = "10.99.0.2";
config = { config, pkgs, ... }: {
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
package = kisassistant.packages.x86_64-linux.default;
host = "0.0.0.0";
enableBrowserDriver = true;
passwordFile = "/var/lib/kisassistant-password"; # provision inside
};
};
};
# Host: nginx proxies to the container (enableNginx stays unset/false)
services.nginx.virtualHosts."chat.kisakay.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://10.99.0.2:3000";
extraConfig = "proxy_buffering off;";
};
};
```
Headless mode needs no display server anywhere (validated: cookie banner
accepted, message sent, response streamed). If the site ever starts
headless-shaming, fall back to `ARCAIC_HEADLESS=false` inside the
container plus an `Xvfb` service — same driver, virtual display.
## 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) |
| `enableBrowserDriver` | bool, `false` | headless Firefox in the service for the `arcaic` driver ("Arcaic-Technology" model; sets `ARCAIC_ENABLED`/`ARCAIC_HEADLESS`/`ARCAIC_EXECUTABLE`, overridable via `extraEnv`) |
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
+231
View File
@@ -0,0 +1,231 @@
# Operations
## Environment
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 <[email protected]>` | 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_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
## 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 (upload a picture in the profile settings —
hosted on the built-in CDN), theme, accent color and feature flags in the settings.
5. Rotate a compromised key with the regenerate button (old sessions die);
delete removes the account with all its chats and shares.
## Admin Center (`/admin`) & self-registration
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
four tabs: **Accounts** (same manager as before, incl. the Ollama model
catalog), **Access** (access-request wishlist triage, see below),
**Features** (server-side switches), **Mail** (SMTP credentials viewer +
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
`POST /api/admin/mail/test`, admin Bearer only).
- `registration_enabled` (default off): when on, the login page shows a
working **Register** button — anyone can create an account (key shown once)
via `POST /api/auth/register`. When off, the button is greyed with the
tooltip *"Registration currently disabled on this platform"* and the
endpoint answers 403. Flipped live, no restart. Turning it on retires the
access-request wishlist automatically.
- `registration_request_enabled` (default on): shows **Request access** on the
login page (wishlist). Only switchable on while public registration is off —
the two are mutually exclusive (API answers 409 otherwise).
- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool.
- Flags live in the `settings` SQLite table, edited via
`GET/PATCH /api/admin/settings` (admin Bearer only).
## Model access policy (kill-switch + rate limits)
The Admin Center **Models** tab lists every model across enabled drivers
(grouped, Ollama library moved there too). Per model id (`"driver:model"`):
an **Enabled** switch and **hourly / daily** per-user request caps
(0 = unlimited, admins always bypass). Policy lives in the `settings` table
(`model_policy` JSON blob, default entries pruned); usage counters in
`model_usage` (fixed UTC hour/day windows, old buckets pruned on write).
- Disabled models vanish from `GET /api/models` for users (admins still see
everything) and `POST /api/chat` rejects them (403) — attempts count
toward limits even when generation later fails; over-limit answers 429.
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
## Personal providers (BYOK)
Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini`
API keys in **Settings → Providers** (per account, no admin involved).
Connected providers add their models to that user's model menu at the top
(no page refresh needed); usage is billed to the user's own provider
account, and the admin model policy (kill-switch + rate limits) still
applies.
- Keys are stored server-side in `user_provider_keys` and **never returned**
to any client (`GET /api/me/providers` exposes presence + last 4 chars
only; never logged). A stored key greys out its field (Replace to rotate,
Remove to disconnect). Deleting the account wipes its keys.
- Routes: `GET /api/me/providers`, `PUT /api/me/providers/:id`
(`{apiKey}`, 8–256 chars), `DELETE /api/me/providers/:id` (all Bearer,
owner-scoped).
## Access-request wishlist (registration approval queue)
When public registration is off, visitors can still reserve a username via
**Request access** on the login page: username + email (required) + a
motivation message (10+ chars). This creates a ticket (`access_requests` +
`access_messages` tables) with a personal page at `/review/<id>` (unguessable
UUID, same pattern as share links).
- Requester: follows the decision, argues their case and replies on the
`/review` page. Closed tickets (accepted/refused) are read-only.
- Admin: **Access** tab in the Admin Center — filter by status, open a ticket,
reply (emailed to the requester), and set `pending → reviewing → accepted /
refused` with a reason. **Accepting creates the account** and returns the
access key once (also emailed). Terminal tickets can't be reopened.
- Email (needs `SMTP_*`): received-confirmation, reviewing/accepted/refused
notices, and every admin reply — all HTML, all stating email is
notification-only and replies belong on the `/review` page.
- Routes: `POST /api/access/request` (public, rate-limited),
`GET /api/access/ticket/:id` + `POST .../message` (public, ticket bearer),
`GET /api/admin/access`, `GET/PATCH /api/admin/access/:id`,
`POST /api/admin/access/:id/message` (admin).
- Live updates ride WebSockets (no polling): `GET /api/access/ws/:id`
(ticket bearer — pushes `access_message` + `access_status` for that ticket)
and `GET /api/admin/ws?token=…` (admin Bearer in query string, browsers
can't set WS headers — pushes `access_message` + `access_status` +
`access_created` for every ticket, driving the AdminCenter badge and the
triage list). Clients ping every 25 s (`{"type":"ping"}` → `{"type":"pong"}`)
and resync over HTTP on every (re)connect.
## Content reports (flagged AI responses)
Every assistant message carries three actions: **Copy**, **Search the web**
(DuckDuckGo, new tab) and **Report**. Reporting opens a modal with a reason
picker (`copyright`, `gore`, `falseinfo`, `bug`) plus optional details, and
creates a row in the `reports` table with a content snapshot (survives later
edits/deletion of the chat), the triggering user prompt, the model, and the
reporter.
- Reporter: capped at 20 open (`open` + `reviewing`) reports (429 beyond).
Reporting the same message twice returns the existing report (409
`already reported`).
- Admin: **Reports** tab in the Admin Center (badge counts open reports,
excluding shadow-banned reporters) — filter by status, expand for the full
snapshot + reporter details, and set `open → reviewing → resolved /
dismissed` with an internal note. An admin can also **shadow-ban** a
noisy reporter (toggle on the report detail, with confirm): their reports
are still accepted but flagged unreliable and hidden from the badge.
- Routes: `POST /api/reports` (Bearer), `GET /api/admin/reports`,
`PATCH /api/admin/reports/:id`,
`POST /api/admin/users/:id/shadowban` (admin).
## 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/<token>` 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/<ns>/<key>` (Bearer) / `GET /cdn/<ns>/<key>.<ext>` (public), e.g.
`/cdn/avatar/<account-id>.png`. Namespaces live in `CDN_NAMESPACES`
(`src/cdn.ts`) — add one to extend (future usage).
- Allowed types **only** `jpg`/`png`/`webp`, verified by **magic bytes**, never
by extension (client filename is ignored; the stored extension comes from
detection). Everything else → `415`, unknown namespace/ext → `404`.
- `avatar` rules: 5 MB max (`413` above), **5 uploads max per 2 h per account**
(`429` + `Retry-After`), ownership enforced (own id, or admin).
- 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`)
Full step-by-step guide: **[NIXOS-HOSTING.md](./NIXOS-HOSTING.md)**.
`flake.nix` exposes `packages.<system>.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; # 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
};
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
`127.0.0.1:3000`.
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1789785513,
"narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+97
View File
@@ -0,0 +1,97 @@
{
description = "KisAssistant — private chat assistant (Bun backend + React frontend, SQLite)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let lib = nixpkgs.lib; in {
nixosModules.default = import ./module.nix;
packages.x86_64-linux.default =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
version = "0.2.0";
# Frontend: two-phase bun build.
# Phase 1 (fixed-output, has network): populate node_modules from
# frontend/bun.lock. If the build fails with a hash mismatch, paste
# the "got: sha256-…" value into bunDepsHash below.
bunDeps = pkgs.stdenv.mkDerivation {
pname = "kisassistant-frontend-bun-deps";
inherit version;
src = ./frontend;
nativeBuildInputs = [ pkgs.bun ];
buildPhase = ''
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
bun install --frozen-lockfile --ignore-scripts
'';
installPhase = ''
cp -r node_modules $out
'';
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-ToEgXq9TpyliuZn+0bu+95SUER+tuXLJ0LHY+c6LUXE=";
};
# Phase 2 (offline): vite build against the pre-fetched node_modules.
frontend = pkgs.stdenv.mkDerivation {
pname = "kisassistant-frontend";
inherit version;
src = ./frontend;
nativeBuildInputs = [ pkgs.bun pkgs.nodejs ];
buildPhase = ''
export HOME=$TMPDIR
cp -a ${bunDeps} node_modules
chmod -R u+w node_modules
patchShebangs node_modules
bun run build
'';
installPhase = ''
mkdir -p $out
cp -r ../public/. $out/
'';
};
# Backend runtime deps from bun.lock (fixed-output, has network).
# Gives the package its node_modules (nodemailer, puppeteer-core, …).
# If the build fails with a hash mismatch, paste the "got: sha256-…"
# value from the error into `outputHash` of `backendDeps` below.
backendDeps = pkgs.stdenv.mkDerivation {
pname = "kisassistant-backend-bun-deps";
inherit version;
src = ./.;
nativeBuildInputs = [ pkgs.bun ];
buildPhase = ''
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
bun install --frozen-lockfile --ignore-scripts
'';
installPhase = ''
cp -r node_modules $out
'';
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-gQmtcFBDcGeMGIZf1VaMG6mlxJ6r08e9iHelS7wvYZ8=";
};
in pkgs.stdenv.mkDerivation {
pname = "kisassistant";
inherit version;
src = ./.;
installPhase = ''
mkdir -p $out/share/kisassistant
cp -r src package.json tsconfig.json .env.example $out/share/kisassistant/
cp -a ${backendDeps} $out/share/kisassistant/node_modules
chmod -R u+w $out/share/kisassistant/node_modules
cp -r ${frontend} $out/share/kisassistant/public
mkdir -p $out/bin
cat > $out/bin/kisassistant <<EOF
#!${pkgs.bash}/bin/bash
cd $out/share/kisassistant
exec ${pkgs.bun}/bin/bun run src/index.ts
EOF
chmod +x $out/bin/kisassistant
'';
};
};
}
+598
View File
@@ -0,0 +1,598 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant-frontend",
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/inter": ["@fontsource-variable/[email protected]", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/[email protected]", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="],
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="],
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["[email protected]", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-jsx-runtime": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-url-attributes": ["[email protected]", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"inline-style-parser": ["[email protected]", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["[email protected]", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-decimal": ["[email protected]", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["[email protected]", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["[email protected]", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["[email protected]", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["[email protected]", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="],
"micromark-extension-gfm-tagfilter": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["[email protected]", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["[email protected]", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["[email protected]", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"node-releases": ["[email protected]", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="],
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"parse-entities": ["[email protected]", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-markdown": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"read-cache": ["[email protected]", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"remark-gfm": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"style-to-js": ["[email protected]", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["[email protected]", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["[email protected]", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unified": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>KisAssistant — private AI chat</title>
<meta
name="description"
content="KisAssistant — a private, self-hosted AI chat assistant. Talk to local Ollama models and cloud LLMs (Mistral, DeepSeek, Anthropic, OpenAI) with server-side chats, public shares and No-KYC accounts."
/>
<meta name="keywords" content="KisAssistant, private AI chat, self-hosted chatbot, Ollama web UI, Mistral, DeepSeek, Anthropic, OpenAI, local LLM" />
<meta name="author" content="kisakay" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#059669" />
<meta name="color-scheme" content="light dark" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="KisAssistant" />
<meta property="og:title" content="KisAssistant — private AI chat" />
<meta
property="og:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats, public shares, No-KYC accounts."
/>
<meta property="og:image" content="/logo.svg" />
<meta property="og:locale" content="en_US" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="KisAssistant — private AI chat" />
<meta
name="twitter:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats and public shares."
/>
<meta name="twitter:image" content="/logo.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/logo.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3833
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "kisassistant-frontend",
"version": "0.2.0",
"description": "KisAssistant React frontend (Vite + Tailwind + lucide-react)",
"author": "kisakay",
"license": "MIT",
"repository": {
"type": "git",
"url": "http://git.kisakay.com/k/chat",
"directory": "frontend"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(60 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(120 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(180 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(240 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(300 50 50)"/>
<circle cx="50" cy="50" r="10" fill="#fbbf24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 656 B

+4
View File
@@ -0,0 +1,4 @@
# KisAssistant — crawlers, LLM agents and scrapers are welcome on public pages.
# Authenticated app routes (/api/* except /api/share/:id) require a Bearer key.
User-agent: *
Allow: /
+654
View File
@@ -0,0 +1,654 @@
import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts";
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
import type {
Attachment,
ChatMessage,
Conversation,
DriverModel,
FilePreview,
User,
} from "./lib/types.ts";
import { navigate, normalizePath, useRoute } from "./lib/route.ts";
import { Login } from "./components/Login.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx";
import {
ConvEditDialog,
FilePreviewModal,
SettingsModal,
ShareModal,
} from "./components/dialogs.tsx";
import { AdminCenter } from "./components/AdminCenter.tsx";
import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx";
import { ReviewPage } from "./components/ReviewPage.tsx";
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
import { useT } from "./lib/i18n.ts";
function shareIdFromPath(): string | null {
const m = window.location.pathname.match(
/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/,
);
return m ? m[1]! : 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;
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
const IS_ADMIN_PAGE =
typeof window !== "undefined" &&
/^\/admin\/?$/.test(window.location.pathname);
function resetTokenFromPath(): string | null {
const m = window.location.pathname.match(
/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/,
);
return m ? m[1]! : null;
}
function reviewIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/review\/([0-9a-f-]{36})\/?$/i);
return m ? m[1]! : null;
}
/** Minimum time the boot splash stays visible, so the bloom animation
* actually plays even when the session restores instantly. */
const MIN_SPLASH_MS = 1100;
/** Max footer-arrow retries for the same failed model prompt. */
const MAX_RETRIES = 3;
function applyTheme(theme: string) {
const root = document.documentElement;
const dark =
theme === "dark" ||
(theme !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
}
export function App() {
const [user, setUser] = useState<User | null>(null);
// Boot splash ONLY on the platform routes (/ and /chat, with a token).
// /login and /register never show it — they render instantly.
const [checking, setChecking] = useState(() => {
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return false;
const p = normalizePath(window.location.pathname);
if (p === "/login" || p === "/register") return false;
return !!getToken();
});
// True once we know whether there is a session (no token = instant).
const [authReady, setAuthReady] = useState(() => !getToken());
const rawRoute = useRoute();
const path = normalizePath(rawRoute);
const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
// Conversation opened from "Archived chats" (read-only, not in the sidebar list).
const [archivedConv, setArchivedConv] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streaming, setStreaming] = useState("");
const [sending, setSending] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileNav, setMobileNav] = useState(false);
const [editConv, setEditConv] = useState<Conversation | null>(null);
const [shareConv, setShareConv] = useState<Conversation | null>(null);
const [deleteConv, setDeleteConv] = useState<Conversation | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null);
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
const [previewBusy, setPreviewBusy] = useState(false);
const [ocrAvailable, setOcrAvailable] = useState(false);
// Model-error retry: which trailing message is the error bubble (if any),
// its conversation, and how many times this prompt was already retried.
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
const [retryCount, setRetryCount] = useState(0);
const { t } = useT();
// Public routes: no auth needed. IDs are constant for the page lifetime.
if (IS_ADMIN_PAGE) {
return <AdminCenter />;
}
if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />;
}
if (REVIEW_ID) {
return <ReviewPage ticketId={REVIEW_ID} />;
}
if (SHARE_ID) {
return <SharePage publicId={SHARE_ID} />;
}
const refreshConvs = useCallback(async (select?: string) => {
try {
const res = await api.convs();
setConvs(res.conversations);
if (select) setActiveId(select);
else if (res.conversations.length > 0) {
setActiveId((cur) =>
cur && res.conversations.some((c) => c.id === cur)
? cur
: res.conversations[0]!.id,
);
}
} catch {
// ignore (session errors reload the page via api layer)
}
}, []);
// Session restore (boot splash stays at least MIN_SPLASH_MS on / and /chat)
useEffect(() => {
const t0 = Date.now();
const done = () => {
const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - t0));
window.setTimeout(() => setChecking(false), wait);
};
if (!getToken()) {
setAuthReady(true);
setChecking(false);
return;
}
api
.verify()
.then(async (res) => {
setUser(res.user);
applyTheme(res.user.theme);
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);
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
})
.catch(() => {})
.finally(() => {
setAuthReady(true);
done();
});
}, []);
// Route guard: / redirects by session, /chat needs auth, /login and
// /register bounce logged-in users to /chat. Public routes return above.
useEffect(() => {
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return;
const noToken = !getToken();
if (path === "/") {
if (noToken) navigate("/login", true);
else if (authReady) navigate(user ? "/chat" : "/login", true);
return;
}
if (path === "/login" || path === "/register") {
if (user) navigate("/chat", true);
return;
}
if (path === "/chat") {
if (noToken || (authReady && !user)) navigate("/login", true);
return;
}
// Unknown path: land on the platform or the login page.
if (noToken) navigate("/login", true);
else if (authReady) navigate(user ? "/chat" : "/login", true);
}, [path, user, authReady]);
// Load messages for active conversation
useEffect(() => {
setFailed(null);
setRetryCount(0);
if (!activeId) {
setMessages([]);
return;
}
api
.getConv(activeId)
.then((res) => setMessages(res.messages))
.catch(() => setMessages([]));
}, [activeId]);
// Follow OS theme when account theme is auto
useEffect(() => {
if (user?.theme !== "auto") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const h = () => applyTheme("auto");
mq.addEventListener("change", h);
return () => mq.removeEventListener("change", h);
}, [user?.theme]);
function handleLogin(u: User) {
setUser(u);
setAuthReady(true);
setChecking(false);
applyTheme(u.theme);
navigate("/chat", true);
api
.models()
.then((m) => {
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();
}
function logout() {
api.logout().finally(() => {});
clearToken();
setUser(null);
setAuthReady(true);
setChecking(false);
setConvs([]);
setMessages([]);
setFailed(null);
setRetryCount(0);
setActiveId(null);
setArchivedConv(null);
setAttachments([]);
setFilePreview(null);
navigate("/login", true);
}
async function newChat() {
try {
setArchivedConv(null);
const res = await api.createConv({ model });
await refreshConvs(res.conversation.id);
} catch {
// fall back to local-only pending state
}
}
/** Stream one assistant reply for an exact history; resolves its text. */
async function streamReply(history: ChatMessage[], convId: string): Promise<string> {
// "Thinking" feature flag: decorate the outgoing payload only — the
// system message is never shown nor persisted client-side.
const outMessages = featureEnabled("thinking")
? [
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
...history,
]
: history;
let full = "";
await api.chatStream({ model, messages: outMessages, conversationId: convId }, (t) => {
full += t;
setStreaming(full);
});
return full;
}
async function send(text: string) {
if (sending || !model || archivedConv) return;
let convId = activeId;
if (!convId) {
try {
const res = await api.createConv({ model });
convId = res.conversation.id;
await refreshConvs(convId);
} catch (err) {
return;
}
}
const userMsg: ChatMessage = { role: "user", content: text };
// Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map(
(a) =>
t("attach.tpl", {
kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"),
name: 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([]);
setFailed(null);
setRetryCount(0);
setSending(true);
setStreaming("");
try {
const full = await streamReply(history, convId!);
setMessages([...history, { role: "assistant", content: full }]);
setStreaming("");
await refreshConvs();
} catch {
setMessages([
...history,
{ role: "assistant", content: t("attach.chatError") },
]);
setFailed({ index: history.length, convId: convId! });
setStreaming("");
} finally {
setSending(false);
}
}
/**
* Re-run the same prompt after a model error (footer retry arrow on the
* error bubble). The trailing error message is dropped and the untouched
* history is streamed again — at most MAX_RETRIES times per prompt.
*/
async function retryFailed() {
if (sending || !failed || retryCount >= MAX_RETRIES || !model || archivedConv) return;
const history = messages.slice(0, failed.index);
const convId = failed.convId;
setMessages(history);
setFailed(null);
setRetryCount((c) => c + 1);
setSending(true);
setStreaming("");
try {
const full = await streamReply(history, convId);
setMessages([...history, { role: "assistant", content: full }]);
setRetryCount(0);
await refreshConvs();
} catch {
setMessages([
...history,
{ role: "assistant", content: t("attach.chatError") },
]);
setFailed({ index: history.length, convId });
setStreaming("");
} finally {
setSending(false);
}
}
/** 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(t("attach.tooBigImg"));
return;
}
if (kind === "text" && file.size > 500 * 1024) {
setAttachError(t("attach.tooBigText"));
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 : t("attach.uploadFailed"));
} 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);
setMessages([]);
}
if (archivedConv?.id === c.id) {
setArchivedConv(null);
setMessages([]);
}
await refreshConvs();
} catch {
// ignore
}
}
/** Archive from the sidebar menu: the chat leaves the side list. */
async function archiveConv(c: Conversation) {
try {
await api.archiveConv(c.id);
if (activeId === c.id) {
setActiveId(null);
setMessages([]);
}
await refreshConvs();
} catch {
// ignore
}
}
/** Open an archived chat (from settings) read-only in the main view. */
function openArchived(c: Conversation) {
setSettingsOpen(false);
setMobileNav(false);
setArchivedConv(c);
setActiveId(null);
setMessages([]);
api
.getConv(c.id)
.then((res) => setMessages(res.messages))
.catch(() => setMessages([]));
}
/** Unarchive the currently viewed archived chat and select it. */
async function unarchiveActive() {
if (!archivedConv) return;
try {
await api.unarchiveConv(archivedConv.id);
const id = archivedConv.id;
setArchivedConv(null);
await refreshConvs(id);
} catch {
// ignore
}
}
/**
* Settings "Archived chats" changed something: refresh the sidebar, and
* drop the read-only view if its conversation was deleted underneath us.
*/
async function handleArchivedChanged(deletedId?: string) {
if (deletedId && archivedConv?.id === deletedId) {
setArchivedConv(null);
setMessages([]);
}
await refreshConvs();
}
/**
* Settings "Providers" changed (key saved/removed): reload the model menu
* so connected providers appear (or disappear) at the top without a
* page refresh. The current pick survives when still available.
*/
async function handleProvidersChanged() {
try {
const m = await api.models();
setModels(m.models);
setModel((cur) =>
cur && m.models.some((x) => x.id === cur) ? cur : (m.models[0]?.id ?? ""),
);
} catch {
// ignore (session errors reload the page via api layer)
}
}
// Auth pages render instantly (no splash); the platform boots behind one.
if (path === "/login") {
if (user) return null; // bounce to /chat imminent
return <Login onLogin={handleLogin} />;
}
if (path === "/register") {
if (user) return null; // bounce to /chat imminent
return <RegisterPage />;
}
if (checking) {
return <LoadingScreen />;
}
// / (or unknown paths) while the guard above redirects.
if (path !== "/chat") {
return null;
}
if (!user) {
return null; // bounce to /login imminent (no splash flash)
}
const active = convs.find((c) => c.id === activeId) ?? archivedConv;
const readOnly = (active?.archived_at ?? 0) !== 0;
return (
<div className="flex h-full">
<Sidebar
user={user}
convs={convs}
activeId={activeId}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(true)}
onNew={() => {
newChat();
setMobileNav(false);
}}
onSelect={(id) => {
setArchivedConv(null);
setActiveId(id);
setMobileNav(false);
}}
onRename={setEditConv}
onTopic={setEditConv}
onShare={setShareConv}
onArchive={archiveConv}
onDelete={setDeleteConv}
onOpenSettings={() => {
setSettingsOpen(true);
setMobileNav(false);
}}
onOpenAdmin={() => {
window.location.href = "/admin";
}}
onLogout={logout}
mobileOpen={mobileNav}
onCloseMobile={() => setMobileNav(false)}
/>
<Chat
user={user}
conv={active}
messages={messages}
streaming={streaming}
sending={sending}
models={models}
model={model}
onModelChange={setModel}
onSend={send}
onShare={() => active && setShareConv(active)}
readOnly={readOnly}
onUnarchive={unarchiveActive}
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}
failedIndex={failed?.index ?? null}
retryCount={retryCount}
maxRetries={MAX_RETRIES}
onRetry={readOnly ? undefined : retryFailed}
/>
<FilePreviewModal
preview={filePreview}
busy={previewBusy}
onClose={() => {
if (!previewBusy) setFilePreview(null);
}}
onAttach={attachPreviewText}
/>
<ConvEditDialog
conv={editConv}
onClose={() => setEditConv(null)}
onSaved={(c) => {
setConvs((prev) => prev.map((x) => (x.id === c.id ? c : x)));
}}
/>
<ShareModal conv={shareConv} onClose={() => setShareConv(null)} />
<ConfirmDialog
open={deleteConv !== null}
onClose={() => setDeleteConv(null)}
title={t("app.deleteConvTitle")}
message={t("app.deleteConvMsg", { title: deleteConv?.title ?? "" })}
onConfirm={() => deleteConv && removeConv(deleteConv)}
/>
<SettingsModal
user={settingsOpen ? user : null}
onClose={() => setSettingsOpen(false)}
onSaved={(u) => {
setUser(u);
applyTheme(u.theme);
}}
// Key rotated or account deleted: sessions are dead server-side.
onKeyRotated={() => {
setSettingsOpen(false);
logout();
}}
onViewArchived={openArchived}
onArchivedChanged={handleArchivedChanged}
onProvidersChanged={handleProvidersChanged}
/>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
pending: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
};
type Filter = "all" | AccessStatus;
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
/** Admin triage for the access-request wishlist: accept / refuse / review. */
export function AccessRequestsPanel() {
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
const [filter, setFilter] = useState<Filter>("pending");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const { t } = useT();
async function refresh(select?: string) {
try {
const res = await api.adminAccessList();
setRequests(res.requests);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
// Live list: the admin firehose pushes every access event (shares its
// socket with the AdminCenter badge via the pool). Resync on
// (re)connect covers events missed while offline.
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") refresh();
},
onSync: () => {
refresh();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visible = requests.filter((r) => filter === "all" || r.status === filter);
const pendingCount = requests.filter((r) => r.status === "pending").length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{FILTERS.map((f) => {
const n = f === "all" ? requests.length : requests.filter((r) => r.status === f).length;
return (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"rounded-full px-3.5 py-1.5 text-xs font-medium capitalize transition",
filter === f
? "bg-stone-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
)}
>
{t(`access.filter.${f}` as StringKey)} · {n}
</button>
);
})}
<button
onClick={() => refresh()}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("access.refresh")}
aria-label={t("access.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!loading && visible.length === 0 && (
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
<Inbox size={16} />
{pendingCount > 0 ? t("access.emptyFiltered") : t("access.empty")}
</p>
)}
<ul className="space-y-2">
{visible.map((r) => (
<li key={r.id}>
<button
onClick={() => setSelectedId((cur) => (cur === r.id ? null : r.id))}
className={cn(
"flex w-full items-center gap-3 rounded-3xl border p-4 text-left transition",
selectedId === r.id
? "border-accent-500/50 bg-accent-500/5"
: "border-stone-200/70 bg-white hover:border-stone-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
)}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-stone-200/70 text-sm font-semibold dark:bg-zinc-800">
{(r.username[0] || "?").toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">@{r.username} <span className="font-normal opacity-50">{r.email}</span></span>
<span className="block truncate text-xs opacity-60">{r.message} · {r.message_count === 1 ? t("access.msgOne") : t("access.msgMany", { n: r.message_count })}</span>
</span>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
{t(`access.status.${r.status}` as StringKey)}
</span>
</button>
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
</li>
))}
</ul>
</div>
);
}
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [reason, setReason] = useState(request.reason);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { t } = useT();
const [freshKey, setFreshKey] = useState<string | null>(null);
// Latest callbacks / server state without restarting the poll loop.
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const known = useRef({ status: request.status, reason: request.reason });
useEffect(() => {
setReason(request.reason);
setFreshKey(null);
known.current = { status: request.status, reason: request.reason };
// Live thread: requester replies arrive instantly, and a status change
// made elsewhere syncs the list (badges, filters). Resync on
// (re)connect covers events missed while offline.
async function fetchOnce() {
try {
const res = await api.adminAccessGet(request.id);
setMessages(res.messages);
if (
res.request.status !== known.current.status ||
res.request.reason !== known.current.reason
) {
known.current = { status: res.request.status, reason: res.request.reason };
onChangedRef.current();
}
} catch {
// Transient failure: keep last state.
}
}
fetchOnce();
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== request.id) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
onChangedRef.current();
} else if (evt.type === "access_status") {
known.current = { status: evt.request.status, reason: evt.request.reason };
onChangedRef.current();
}
},
onSync: () => {
fetchOnce();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id]);
const closed = request.status === "accepted" || request.status === "refused";
async function setStatus(status: AccessStatus) {
setBusy(true);
setError("");
try {
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
if (res.key) setFreshKey(res.key);
known.current = { status: res.request.status, reason: res.request.reason };
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("access.updateFailed"));
} finally {
setBusy(false);
}
}
async function reply(e: React.FormEvent) {
e.preventDefault();
const body = draft.trim();
if (!body || busy) return;
setBusy(true);
setError("");
try {
const res = await api.adminAccessReply(request.id, body);
setMessages((prev) => [...prev, res.message]);
setDraft("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
{freshKey && (
<div className="rounded-2xl border border-accent-500/40 bg-accent-50 p-3 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><CheckCircle2 size={15} /> {t("access.accountCreated")}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey}</code>
<CopyButton text={freshKey} />
</div>
</div>
)}
<div className="max-h-64 space-y-2 overflow-y-auto">
{messages.map((m) => (
<div key={m.id} className={cn("flex", m.author === "admin" ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[85%] rounded-2xl px-3 py-2 text-sm",
m.author === "admin"
? "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950"
: "rounded-tl-lg bg-white dark:bg-zinc-800",
)}>
<p className="mb-0.5 text-[11px] font-medium opacity-70">
{m.author === "admin" ? t("access.youAdmin") : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}
</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</div>
</div>
))}
</div>
<form onSubmit={reply} className="flex items-end gap-2">
<textarea
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t("access.replyPh")}
aria-label={t("access.replyAria")}
className="max-h-32 flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!draft.trim() || busy}
aria-label={t("chat.send")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
{busy ? <Spinner size={15} /> : <SendHorizontal size={15} />}
</button>
</form>
{!closed ? (
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="min-w-48 flex-1">
<Field label={t("access.reasonLabel")}>
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("access.reasonPh")} maxLength={500} />
</Field>
</div>
<div className="flex gap-2">
{request.status === "pending" && (
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
<Search size={14} /> {t("access.btnReviewing")}
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => setStatus("accepted")}>
<CheckCircle2 size={14} /> {t("access.btnAccept")}
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("refused")} className="!text-red-600 dark:!text-red-400">
<XCircle size={14} /> {t("access.btnRefuse")}
</Button>
</div>
</div>
) : (
<p className="flex items-center gap-2 text-xs opacity-60">
<Clock size={13} /> {t("access.closedNote", { status: t(`access.status.${request.status}` as StringKey).toLowerCase() })}
{request.reason && <> {t("access.reasonWord")}: <em>{request.reason}</em></>}
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { useEffect, useState } from "react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api, getToken } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
function applyTheme(theme: string) {
const root = document.documentElement;
const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
}
export function AdminCenter() {
const { t } = useT();
const [allowed, setAllowed] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>("accounts");
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
const [openReportCount, setOpenReportCount] = useState(0);
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
{ id: "reports", label: t("center.tabReports"), icon: Flag },
{ id: "models", label: t("center.tabModels"), icon: Boxes },
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail },
];
useEffect(() => {
api.verify()
.then((r) => {
if (!r.user.isAdmin) {
setAllowed(false);
return;
}
setAllowed(true);
applyTheme(r.user.theme);
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
})
.catch(() => setAllowed(false));
}, []);
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
// event, so the open counts stay fresh with zero polling (reports re-fetch
// alongside). Resync on (re)connect covers events missed while offline.
useEffect(() => {
if (!allowed) return;
async function fetchCount() {
try {
const res = await api.adminAccessList();
setOpenAccessCount(
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
);
} catch {
// Transient failure: keep last count.
}
try {
const res = await api.adminReportList();
setOpenReportCount(
res.reports.filter((r) => (r.status === "open" || r.status === "reviewing") && r.reporter_shadowbanned !== 1).length,
);
} catch {
// Transient failure: keep last count.
}
}
fetchCount();
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") fetchCount();
},
onSync: () => {
fetchCount();
},
});
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
setSaving(true);
setError("");
try {
const res = await api.adminPatchSettings(patch);
setSettings(res.settings);
} catch (e) {
setError(e instanceof Error ? e.message : t("common.saveFailed"));
} finally {
setSaving(false);
}
}
if (allowed === null) {
return <LoadingScreen />;
}
if (!allowed) {
return (
<div className="grid min-h-full place-items-center p-6">
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200 bg-white p-8 text-center shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<ShieldAlert size={28} className="mx-auto mb-3 text-red-500" />
<h1 className="text-lg font-bold">{t("center.locked")}</h1>
<p className="mt-1 text-sm opacity-60">{t("center.lockedSub")}</p>
<a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">{t("common.backToLogin")}</a>
</div>
</div>
);
}
return (
<div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-4 py-3">
<a href="/" aria-label={t("center.back")} className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<ArrowLeft size={18} />
</a>
<FlowerMark size={32} dynamic={false} />
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" />
{t("center.title")}
</h1>
</div>
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
<div className="flex gap-1 overflow-x-auto rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
tab === t.id
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
: "opacity-60 hover:opacity-100",
)}
>
<t.icon size={15} />
{t.label}
{t.id === "access" && openAccessCount > 0 && (
<span
role="status"
aria-label={`${t.label}: ${openAccessCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
>
{openAccessCount > 99 ? "99+" : openAccessCount}
</span>
)}
{t.id === "reports" && openReportCount > 0 && (
<span
role="status"
aria-label={`${t.label}: ${openReportCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
>
{openReportCount > 99 ? "99+" : openReportCount}
</span>
)}
</button>
))}
</div>
</div>
</header>
<main className="mx-auto w-full max-w-4xl space-y-4 px-4 py-6">
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{tab === "accounts" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<AdminPanel open bare onClose={() => {}} />
</section>
)}
{tab === "access" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<AccessRequestsPanel />
</section>
)}
{tab === "reports" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<ReportsPanel />
</section>
)}
{tab === "models" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<ModelsPanel />
</section>
)}
{tab === "features" && (
<section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.regTitle")}</p>
<p className="text-sm opacity-60">{t("center.regDesc")}</p>
</div>
<Switch label={t("center.regTitle")} checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} />
</div>
<div className={cn(
"flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
settings?.registrationEnabled && "opacity-60",
)}>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.accTitle")}</p>
<p className="text-sm opacity-60">
{settings?.registrationEnabled
? t("center.accDescOn")
: t("center.accDescOff")}
</p>
</div>
<Switch
label={t("center.accTitle")}
checked={settings?.accessRequestEnabled ?? false}
disabled={settings?.registrationEnabled ?? false}
onChange={(v) => toggle({ accessRequestEnabled: v })}
/>
</div>
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.ocrTitle")}</p>
<p className="text-sm opacity-60">{t("center.ocrDesc")}</p>
</div>
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
</div>
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
</section>
)}
{tab === "mail" && <MailPanel />}
</main>
</div>
);
}
type SmtpInfo = {
enabled: boolean;
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
hasPass: boolean;
from: string;
appUrl: string;
};
function MailPanel() {
const { t } = useT();
const [smtp, setSmtp] = useState<SmtpInfo | null>(null);
const [error, setError] = useState("");
const [showPass, setShowPass] = useState(false);
const [to, setTo] = useState("");
const [verifying, setVerifying] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
}, []);
async function verify() {
setVerifying(true);
setResult(null);
try {
await api.adminMailVerify();
setResult({ ok: true, text: t("center.connOk") });
} catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.verifyFailed") });
} finally {
setVerifying(false);
}
}
async function sendTest() {
setSending(true);
setResult(null);
try {
const r = await api.adminMailTest(to.trim());
setResult({ ok: true, text: t("center.testSent", { to: to.trim(), tail: r.messageId ? ` (${r.messageId})` : "" }) });
} catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.mailSendFailed") });
} finally {
setSending(false);
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 py-2">
<span className="w-24 shrink-0 text-xs font-medium uppercase tracking-wider opacity-50">{label}</span>
<span className="min-w-0 flex-1 truncate text-sm">{children}</span>
</div>
);
}
return (
<div className="space-y-4">
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Mail size={17} />
</span>
<div>
<p className="text-sm font-medium">{t("center.mailTitle")}</p>
<p className="text-sm opacity-60">
{smtp == null ? t("center.mailChecking") : smtp.enabled ? t("center.mailFrom", { from: smtp.from }) : t("center.mailOff")}
</p>
</div>
<span className={cn(
"ml-auto rounded-full px-3 py-1 text-xs font-medium",
smtp?.enabled ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-stone-200/70 dark:bg-zinc-800",
)}>
{smtp?.enabled ? t("center.on") : t("center.off")}
</span>
</div>
{error && <p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{smtp && (
<div className="mt-3 divide-y divide-stone-200/70 rounded-2xl border border-stone-200/70 px-4 dark:divide-zinc-800 dark:border-zinc-800">
<Row label={t("center.smtpHost")}>{smtp.host || <span className="opacity-50">{t("center.notSet")}</span>}</Row>
<Row label={t("center.smtpPort")}>{smtp.port} · {smtp.secure ? t("center.tlsImplicit") : t("center.tlsStarttls")}</Row>
<Row label={t("center.smtpUser")}>{smtp.user || <span className="opacity-50">{t("center.noAuth")}</span>}</Row>
<Row label={t("center.smtpPass")}>
<span className="flex items-center gap-2">
{!smtp.hasPass ? (
<span className="opacity-50">{t("center.notSet")}</span>
) : showPass ? (
<code className="truncate font-mono">{smtp.pass}</code>
) : (
<code className="select-none blur-sm" aria-label={t("center.hiddenPass")}>••••••••••</code>
)}
{smtp.hasPass && (
<>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
aria-label={showPass ? t("center.hidePass") : t("center.showPass")}
className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"
>
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
{showPass && <CopyButton text={smtp.pass} />}
</>
)}
</span>
</Row>
<Row label={t("center.smtpFrom")}><span className="truncate">{smtp.from}</span></Row>
<Row label={t("center.smtpAppUrl")}><span className="truncate">{smtp.appUrl}</span></Row>
</div>
)}
<p className="mt-3 text-sm opacity-60">
{t("center.credsNote")}
</p>
</section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">{t("center.mailTestTitle")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("center.mailTestDesc")}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={verify} disabled={verifying || !smtp?.enabled}>
{verifying ? <Spinner size={14} /> : null}
{verifying ? t("center.verifying") : t("center.verifyConn")}
</Button>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<Input
type="email"
placeholder="[email protected]"
aria-label={t("center.testToAria")}
value={to}
onChange={(e) => setTo(e.target.value)}
className="flex-1"
/>
<Button size="sm" onClick={sendTest} disabled={sending || !smtp?.enabled || !/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(to.trim())}>
{sending ? <Spinner size={14} /> : <Send size={14} />}
{sending ? t("center.sendingMail") : t("center.sendTest")}
</Button>
</div>
{result && (
<p
role={result.ok ? "status" : "alert"}
className={cn(
"mt-3 rounded-2xl px-4 py-2.5 text-sm",
result.ok
? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
{result.text}
</p>
)}
</section>
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
import { useEffect, useState, type FC, type ReactNode } from "react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { DriverModel, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx";
/** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */
const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [
{ name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] },
{ name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] },
{ name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
{ name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] },
{ name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] },
{ name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] },
{ name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
{ name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] },
{ name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] },
{ name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] },
];
/* ---------- Ollama model catalog (admin) ---------- */
export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useT();
const [installed, setInstalled] = useState<DriverModel[]>([]);
const [pullName, setPullName] = useState("");
const [pullStatus, setPullStatus] = useState("");
const [pullPct, setPullPct] = useState<number | null>(null);
const [pulling, setPulling] = useState(false);
const [error, setError] = useState("");
const [deleteModel, setDeleteModel] = useState<string | null>(null);
const [sizeOverrides, setSizeOverrides] = useState<Record<string, string>>({});
async function refreshInstalled() {
try {
// Admin view: force a fresh upstream fetch, bypassing the 60s user cache.
const res = await api.models({ refresh: true });
setInstalled(res.models.filter((m) => m.driver === "ollama"));
} catch { /* ignore */ }
}
useEffect(() => {
if (open) {
setError("");
setPullStatus("");
setPullPct(null);
setPulling(false);
refreshInstalled();
}
}, [open]);
async function pull(fullName: string) {
if (pulling || !fullName.trim()) return;
setPulling(true);
setError("");
setPullStatus(t("ollama.contacting"));
setPullPct(null);
try {
await api.ollamaPull(fullName.trim(), (p) => {
if (p.error) { setError(p.error); return; }
setPullStatus(p.status || t("ollama.pullingSt"));
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
else setPullPct(null);
});
setPullStatus(t("ollama.doneSt"));
setPullPct(100);
await refreshInstalled();
} catch (err) {
setError(err instanceof Error ? err.message : t("ollama.pullFailed"));
} finally {
setPulling(false);
}
}
async function removeModel(name: string) {
try {
await api.ollamaDelete(name);
await refreshInstalled();
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
}
}
return (
<>
<Modal open={open} onClose={pulling ? () => {} : onClose} title={t("ollama.models")} icon={Bot}>
<p className="mb-4 text-sm opacity-70">
{t("ollama.desc")}
</p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
{pulling && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Download size={15} className="animate-bounce" />
{t("ollama.pulling", { name: pullName })}
</p>
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
{pullPct !== null && (
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800">
<div className="h-full rounded-full bg-accent-600 transition-all dark:bg-accent-500" style={{ width: `${pullPct}%` }} />
</div>
)}
</div>
)}
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-52 flex-1">
<Field label={t("ollama.customTag")} hint={t("ollama.customHint")}>
<Input
value={pullName}
onChange={(e) => setPullName(e.target.value)}
placeholder="llama3.2:3b"
disabled={pulling}
onKeyDown={(e) => e.key === "Enter" && pull(pullName)}
/>
</Field>
</div>
<Button size="sm" onClick={() => pull(pullName)} disabled={pulling || !pullName.trim()}>
{pulling ? <Spinner size={15} /> : <Download size={15} />} {t("ollama.pull")}
</Button>
</div>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> {t("ollama.installed", { n: installed.length })}</p>
<ul className="mb-5 space-y-1.5">
{installed.length === 0 && <li className="text-sm opacity-50">{t("ollama.noneYet")}</li>}
{installed.map((m) => (
<li key={m.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1 truncate text-sm font-medium">{m.label || m.id}</span>
<button
title={t("ollama.delTip", { name: m.label || m.id })}
onClick={() => setDeleteModel(m.label || m.id)}
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
>
<Trash2 size={15} />
</button>
</li>
))}
</ul>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> {t("ollama.catalog")}</p>
<ul className="space-y-1.5">
{OLLAMA_CATALOG.map((m) => {
const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : "");
const tag = size ? `${m.name}:${size}` : m.name;
return (
<li key={m.name} className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{m.name}</span>
<span className="block text-xs opacity-50">{t(m.catKey)}</span>
</span>
{m.sizes.length > 0 && (
<span className="w-24 shrink-0">
<Picker
ariaLabel={t("ollama.sizeAria", { name: m.name })}
value={size}
onChange={(v) => setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))}
align="right"
options={m.sizes.map((s) => ({ value: s, label: s }))}
/>
</span>
)}
<Button size="sm" variant="secondary" onClick={() => { setPullName(tag); pull(tag); }} disabled={pulling}>
<Download size={14} /> {t("ollama.pull")}
</Button>
</li>
);
})}
</ul>
</Modal>
<ConfirmDialog
open={deleteModel !== null}
onClose={() => setDeleteModel(null)}
title={t("ollama.delTitle", { name: deleteModel ?? "" })}
message={t("ollama.delMsg")}
onConfirm={() => deleteModel && removeModel(deleteModel)}
/>
</>
);
}
export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) {
const { t } = useT();
const [users, setUsers] = useState<User[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [sort, setSort] = useState("newest");
const [filter, setFilter] = useState("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const PER_PAGE = 8;
// create form
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
async function refresh() {
setBusy(true);
setError("");
try {
const res = await api.adminList({ q: debouncedQ || undefined, sort, filter, page, per: PER_PAGE });
setUsers(res.users);
setTotal(res.total);
setPages(res.pages);
if (res.page !== page) setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
setBusy(false);
}
}
useEffect(() => {
if (open || bare) {
refresh();
setShowCreate(false);
setFreshKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, bare ]);
// Debounced search (resets to first page); sort/filter reload too.
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQ(q);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [q ]);
useEffect(() => {
if (open || bare) refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQ, sort, filter, page ]);
async function create(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
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) {
setError(err instanceof Error ? err.message : t("admin.createFailed"));
}
}
async function regenerate(u: User) {
setError("");
try {
const res = await api.adminRegenerate(u.id);
setFreshKey({ username: u.username, key: res.key });
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.regenFailed"));
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
}
}
// Bare mode embeds the same manager in the /admin Center page instead
// of a modal — no logic duplicated.
const Shell: FC<{ children: ReactNode }> = bare
? ({ children }) => <div className="min-w-0 flex-1 space-y-1">{children}</div>
: ({ children }) => <Modal open={open} onClose={onClose} title={t("admin.title")} icon={Users} wide>{children}</Modal>;
return (
<>
<Shell>
<p className="mb-4 text-sm opacity-70">
{t("admin.intro")}
</p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{freshKey && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> {t("admin.keyOnce", { user: freshKey.username })}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code>
<CopyButton text={freshKey.key} />
</div>
</div>
)}
{!showCreate ? (
<div className="mb-5 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
<UserPlus size={15} /> {t("admin.newAccount")}
</Button>
</div>
) : (
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-40 flex-1">
<Field label={t("common.username")}><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
</div>
<Button size="sm" type="submit">{t("common.create")}</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>{t("common.cancel")}</Button>
</form>
)}
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-40 flex-1">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("admin.searchPh")} aria-label={t("admin.searchAria")} className="pl-10" />
</div>
<Picker
ariaLabel={t("admin.sortAria")}
icon={ArrowUpDown}
value={sort}
onChange={(v) => { setSort(v); setPage(1); }}
align="right"
options={[
{ value: "newest", label: t("admin.sortNewest") },
{ value: "oldest", label: t("admin.sortOldest") },
{ value: "az", label: t("admin.sortAz") },
{ value: "za", label: t("admin.sortZa") },
]}
/>
<Picker
ariaLabel={t("admin.filterAria")}
icon={Filter}
value={filter}
onChange={(v) => { setFilter(v); setPage(1); }}
align="right"
options={[
{ value: "all", label: t("admin.filterAll") },
{ value: "with-email", label: t("admin.filterWith") },
{ value: "no-email", label: t("admin.filterNo") },
]}
/>
</div>
{busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>
) : (
<ul className="space-y-2">
{users.map((u) => (
<li key={u.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<Avatar name={u.displayName} url={u.avatarUrl} size={36} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
<p className="truncate text-xs opacity-50">{u.email || t("admin.noEmail")} · {new Date(u.createdAt).toLocaleDateString()}</p>
</div>
{!u.isAdmin && (
<div className="flex shrink-0 gap-1">
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div>
)}
</li>
))}
</ul>
)}
<div className="mt-4 flex items-center justify-between gap-2 pt-1 text-sm">
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
{pages > 1 && (
<div className="flex gap-2.5">
<IconButton
title={t("admin.prevPage")}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={16} />
</IconButton>
<IconButton
title={t("admin.nextPage")}
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</IconButton>
</div>
)}
</div>
</Shell>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t("admin.deleteTitle", { user: deleteTarget?.username ?? "" })}
message={t("admin.deleteMsg")}
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
</>
);
}
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const { t } = useT();
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState("");
const [theme, setTheme] = useState("auto");
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setEmail(user.email);
setTheme(user.theme);
setError("");
}
}, [user]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!user) return;
try {
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
}
}
return (
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
<Field label={t("common.theme")}>
<Picker
ariaLabel={t("common.theme")}
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: t("common.themeAuto") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
]}
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" type="submit">{t("common.save")}</Button>
</div>
</form>
</Modal>
);
}
+478
View File
@@ -0,0 +1,478 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { api, ApiError, type ReportReason } from "../lib/api.ts";
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { UserMessageContent } from "./MessageContent.tsx";
import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
const { t } = useT();
const isUser = msg.role === "user";
const [copied, setCopied] = useState(false);
function copy() {
navigator.clipboard.writeText(msg.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}).catch(() => {});
}
function searchWeb() {
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
}
return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
{isUser
? <Avatar name={authorName} url={authorAvatar} size={32} />
: <AssistantAvatar size={32} />}
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
{isUser ? t("chat.you") : "KisAssistant"}
</div>
{isUser ? (
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
<UserMessageContent content={msg.content} />
</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={msg.content} />
</div>
)}
{!isUser && (onReport || (isFailed && onRetry)) && (
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
{onReport && (
<>
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
</IconButton>
<IconButton title={t("msg.search")} onClick={searchWeb}>
<Search size={14} />
</IconButton>
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
<Flag size={14} />
</IconButton>
</>
)}
{isFailed && onRetry && (
<IconButton
title={`${t("msg.retry")} (${Math.min(retryCount ?? 0, maxRetries ?? 0)}/${maxRetries ?? 0})`}
onClick={onRetry}
disabled={retryDisabled}
>
<RotateCcw size={14} />
</IconButton>
)}
</div>
)}
</div>
</div>
);
}
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
open: boolean;
onClose: () => void;
conversationId: string | null;
messageIndex: number;
content: string;
prompt: string;
model: string;
}) {
const { t } = useT();
const [reason, setReason] = useState("");
const [details, setDetails] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
const [duplicate, setDuplicate] = useState(false);
useEffect(() => {
if (open) {
setReason("");
setDetails("");
setError("");
setSent(false);
setDuplicate(false);
}
}, [open, messageIndex]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!reason || busy) return;
setBusy(true);
setError("");
try {
await api.createReport({
conversationId: conversationId ?? undefined,
messageIndex,
reason: reason as ReportReason,
details: details.trim(),
content,
prompt,
model,
});
setSent(true);
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setDuplicate(true);
} else {
setError(err instanceof Error ? err.message : t("report.failed"));
}
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title={t("report.title")} icon={Flag}>
{sent || duplicate ? (
<div className="space-y-4">
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
<Check size={16} /> {duplicate ? t("report.alreadyReported") : t("report.sent")}
</p>
<div className="flex justify-end">
<Button variant="secondary" onClick={onClose}>{t("common.close")}</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<Field label={t("report.reason")}>
<Picker
value={reason}
onChange={setReason}
options={REPORT_REASONS.map((r) => ({ value: r, label: t(`report.reason.${r}` as StringKey) }))}
placeholder={t("report.reasonPh")}
ariaLabel={t("report.reason")}
align="left"
/>
</Field>
<Field label={t("report.details")}>
<textarea
rows={3}
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder={t("report.detailsPh")}
aria-label={t("report.details")}
maxLength={2000}
className="max-h-40 w-full resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" disabled={!reason || busy}>
{busy ? <Spinner size={14} /> : <Flag size={14} />} {t("report.submit")}
</Button>
</div>
</form>
)}
</Modal>
);
}
export function Chat({
user,
conv,
messages,
streaming,
sending,
models,
model,
onModelChange,
onSend,
onShare,
onUnarchive,
readOnly,
sidebarCollapsed,
onExpandSidebar,
onOpenNav,
attachments,
attachError,
ocrAvailable,
onRemoveAttachment,
onPickFile,
failedIndex,
retryCount,
maxRetries,
onRetry,
}: {
user: User;
conv: Conversation | null;
messages: ChatMessage[];
streaming: string;
sending: boolean;
models: DriverModel[];
model: string;
onModelChange: (m: string) => void;
onSend: (text: string) => void;
onShare: () => void;
/** Archived chats render read-only; the banner offers to unarchive. */
readOnly?: boolean;
onUnarchive?: () => void;
sidebarCollapsed: boolean;
onExpandSidebar: () => void;
onOpenNav: () => void;
attachments: Attachment[];
attachError: string | null;
ocrAvailable: boolean;
onRemoveAttachment: (id: string) => void;
onPickFile: (kind: "ocr" | "text", file: File) => void;
/** Trailing model-error bubble index (retry arrow), if any. */
failedIndex: number | null;
retryCount: number;
maxRetries: number;
onRetry?: () => void;
}) {
const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false);
const [reportIndex, setReportIndex] = useState<number | null>(null);
const { t } = useT();
const features = useFeatures();
const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const imgRef = useRef<HTMLInputElement>(null);
const txtRef = useRef<HTMLInputElement>(null);
const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
for (const m of models) {
const group = m.group ?? m.driver;
const list = byDriver.get(group) ?? [];
list.push({ value: m.id, label: m.label, hint: m.id });
byDriver.set(group, list);
}
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
})();
useEffect(() => {
const el = boxRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, streaming]);
function autoGrow() {
const ta = taRef.current;
if (ta) {
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 200) + "px";
}
}
function submit(e?: React.FormEvent) {
e?.preventDefault();
if (readOnly) return;
const text = draft.trim();
if (!text || sending) return;
setDraft("");
requestAnimationFrame(autoGrow);
onSend(text);
}
return (
<div className="flex h-full min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
<IconButton title={t("chat.openNav")} onClick={onOpenNav} className="md:hidden">
<Menu size={18} />
</IconButton>
{sidebarCollapsed && (
<span className="hidden md:inline">
<IconButton title={t("chat.expand")} onClick={onExpandSidebar}>
<PanelLeftOpen size={18} />
</IconButton>
</span>
)}
<span className="hidden items-center gap-2 font-semibold sm:flex">
<FlowerMark size={24} dynamic={false} />
{conv?.title || t("sidebar.newChat")}
</span>
{conv?.topic && (
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
)}
<div className="ml-auto flex items-center gap-2">
<Picker
ariaLabel={t("chat.modelAria")}
icon={Cpu}
value={model}
onChange={onModelChange}
groups={modelGroups}
placeholder={models.length === 0 ? t("chat.noModels") : t("chat.selectModel")}
disabled={models.length === 0}
/>
{conv && !readOnly && (
<IconButton title={t("sidebar.share")} onClick={onShare}>
<Share2 size={17} />
</IconButton>
)}
</div>
</header>
<div ref={boxRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
<div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && (
<div className="py-16 text-center">
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
<h2 className="text-2xl font-bold tracking-tight">{t("chat.greet", { name: user.displayName.split(" ")[0]! })}</h2>
<p className="mt-1 text-sm opacity-60">{t("chat.greetSub")}</p>
</div>
)}
{messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} />
))}
{streaming !== "" && (
<div className="flex gap-3">
<AssistantAvatar size={32} />
<div className="min-w-0 max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="typing-cursor"><Markdown text={streaming} /></div>
</div>
</div>
</div>
)}
{sending && streaming === "" && (
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
)}
</div>
</div>
<div className="px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-8">
<div className="mx-auto w-full max-w-3xl">
{attachError && (
<p className="mb-2 rounded-2xl bg-red-500/10 px-4 py-2 text-sm text-red-600 dark:text-red-400" role="alert">{attachError}</p>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10">
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
<span className="max-w-40 truncate">{a.name}</span>
<button onClick={() => onRemoveAttachment(a.id)} aria-label={t("chat.removeAttach", { name: a.name })} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
<X size={13} />
</button>
</span>
))}
</div>
)}
</div>
<div className="relative mx-auto w-full max-w-3xl">
{attachOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAttachOpen(false)} />
<div className="absolute bottom-full left-0 z-50 mb-2 w-80 max-w-[calc(100vw-2rem)] rounded-3xl border border-stone-200 bg-white p-2 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900">
<button
onClick={() => { setAttachOpen(false); imgRef.current?.click(); }}
disabled={!ocrAvailable}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<ScanText size={16} />
</span>
<span>
<span className="block text-sm font-medium">{t("chat.ocrOpt")} {ocrAvailable ? "" : t("chat.unavailable")}</span>
<span className="block text-xs opacity-60">{t("chat.ocrHint")}</span>
</span>
</button>
<button
onClick={() => { setAttachOpen(false); txtRef.current?.click(); }}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<FileText size={16} />
</span>
<span>
<span className="block text-sm font-medium">{t("chat.textOpt")}</span>
<span className="block text-xs opacity-60">{t("chat.textHint")}</span>
</span>
</button>
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
{t("chat.attachNote")}
</p>
</div>
</>
)}
</div>
{readOnly ? (
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 rounded-[1.75rem] border border-stone-200 bg-stone-100 p-2 pl-4 shadow-lg dark:border-zinc-700 dark:bg-zinc-800/60">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Archive size={17} />
</span>
<p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p>
<button
type="button"
onClick={onUnarchive}
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-accent-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
<ArchiveRestore size={15} />
{t("archived.unarchive")}
</button>
</div>
) : (
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
{features.attachments && (
<button
type="button"
onClick={() => setAttachOpen((o) => !o)}
aria-label={t("chat.attachTip")}
title={t("chat.attachTip")}
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
>
<Paperclip size={17} className="opacity-70" />
</button>
)}
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
<textarea
ref={taRef}
rows={1}
value={draft}
onChange={(e) => { setDraft(e.target.value); autoGrow(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={t("chat.placeholder")}
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
style={{ maxHeight: 200 }}
/>
<button
type="submit"
disabled={(!draft.trim() && attachments.length === 0) || sending}
aria-label={t("chat.send")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
<SendHorizontal size={17} />
</button>
</form>
)}
<p className="py-2 text-center text-xs opacity-50">
<a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
<BookOpen size={12} />
{t("chat.wiki")}
</a>
</p>
</div>
{(() => {
const shown = messages.filter((m) => m.role !== "system");
const target = reportIndex !== null ? shown[reportIndex] : undefined;
const prev = reportIndex !== null && reportIndex > 0 ? shown[reportIndex - 1] : undefined;
return (
<ReportDialog
open={reportIndex !== null}
onClose={() => setReportIndex(null)}
conversationId={conv?.id ?? null}
messageIndex={reportIndex ?? 0}
content={target?.content ?? ""}
prompt={prev && prev.role === "user" ? prev.content : ""}
model={conv?.model || model}
/>
);
})()}
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useState } from "react";
import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Zap } from "lucide-react";
import { api, setToken } from "../lib/api.ts";
import { useT } from "../lib/i18n.ts";
import type { User as UserType } from "../lib/types.ts";
import { navigate } from "../lib/route.ts";
import { Button, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
import { RecoverDialog } from "./dialogs.tsx";
import { AccessRequestModal } from "./ReviewPage.tsx";
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const { t } = useT();
const [username, setUsername] = useState("admin");
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [recoverOpen, setRecoverOpen] = useState(false);
const [recoveryOn, setRecoveryOn] = useState(false);
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
const [registrationOn, setRegistrationOn] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
const [requestOn, setRequestOn] = useState(false);
const PERKS = [
{ icon: ShieldCheck, text: t("login.perk1") },
{ icon: Zap, text: t("login.perk2") },
];
// TOTP second step (set when the server answers totpRequired).
const [totpToken, setTotpToken] = useState<string | null>(null);
const [totpUsername, setTotpUsername] = useState("");
const [code, setCode] = useState("");
useEffect(() => {
api.methods().then((m) => {
setRecoveryOn(m.recovery);
setRecoveryFrom(m.from);
setRegistrationOn(m.registration);
setRequestOn(m.accessRequest);
}).catch(() => {});
// Fresh registration hands the new key over via sessionStorage so the
// login form comes prefilled (cleared immediately after reading).
try {
const raw = sessionStorage.getItem("ka_login_prefill");
if (raw) {
const p = JSON.parse(raw) as { username?: string; key?: string };
if (p.username) setUsername(p.username);
if (p.key) setKey(p.key);
sessionStorage.removeItem("ka_login_prefill");
}
} catch {
// ignore (private mode, malformed payload)
}
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setError("");
setBusy(true);
try {
const res = await api.login(username.trim().toLowerCase(), key);
if ("totpRequired" in res) {
setTotpToken(res.totpToken);
setTotpUsername(res.username);
return;
}
setToken(res.token);
onLogin(res.user);
} catch (err) {
setError(err instanceof Error ? err.message : t("login.failed"));
} finally {
setBusy(false);
}
}
async function submitTotp(e: React.FormEvent) {
e.preventDefault();
if (busy || !totpToken) return;
setError("");
setBusy(true);
try {
const res = await api.totpLogin(totpToken, code);
setToken(res.token);
onLogin(res.user);
} catch (err) {
setError(err instanceof Error ? err.message : t("login.totpInvalid"));
} finally {
setBusy(false);
}
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-md">
<div className="rounded-[2rem] border border-white/60 bg-white/80 px-6 py-6 shadow-2xl shadow-accent-900/5 backdrop-blur-xl sm:px-8 dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-1 flex flex-col items-center text-center">
<FlowerMark size={52} dynamic={false} className="mb-2 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("login.welcome")}</h1>
<p className="mt-0.5 text-sm opacity-60">{t("login.subtitle")}</p>
</div>
{totpToken ? (
<form onSubmit={submitTotp} className="mt-4 space-y-2.5">
<p className="text-center text-sm opacity-70">
{t("login.totpIntro", { user: totpUsername })}
</p>
<div className="relative">
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
variant="soft"
className="pl-12 text-center tracking-[0.5em]"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
autoComplete="one-time-code"
placeholder="000000"
aria-label={t("login.totpAria")}
inputMode="numeric"
required
/>
</div>
{error && (
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
<Button type="submit" variant="gradient" size="lg" className="w-full" disabled={busy || code.length !== 6}>
{busy ? <Spinner /> : (<>{t("login.verify")} <ArrowRight size={17} /></>)}
</Button>
<button
type="button"
onClick={() => { setTotpToken(null); setCode(""); setError(""); }}
className="w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"
>
{t("common.backToLogin")}
</button>
</form>
) : (
<form onSubmit={submit} className="mt-4 space-y-2.5">
<div className="relative">
<User size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
variant="soft"
className="pl-12"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
placeholder={t("login.usernamePh")}
aria-label={t("login.usernamePh")}
required
/>
</div>
<div className="relative">
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
variant="soft"
className="pl-12"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
autoComplete="current-password"
placeholder={t("login.keyPh")}
aria-label={t("login.keyPh")}
required
/>
</div>
{error && (
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
<Button type="submit" variant="gradient" size="lg" className="w-full" disabled={busy}>
{busy ? <Spinner /> : (<>{t("login.unlock")} <ArrowRight size={17} /></>)}
</Button>
</form>
)}
<div className="mt-3 flex items-center gap-3 text-xs opacity-40">
<span className="h-px flex-1 bg-current" />
{t("login.newHere")}
<span className="h-px flex-1 bg-current" />
</div>
<Button
variant="secondary"
size="lg"
className="mt-2 w-full"
disabled={!registrationOn}
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
onClick={() => navigate("/register")}
>
<UserPlus size={16} />
{t("login.register")}
</Button>
{/* Wishlist path: only when open registration is off AND the admin
enabled access requests. They are mutually exclusive server-side. */}
{!registrationOn && requestOn && (
<Button
variant="secondary"
size="lg"
className="mt-2 w-full"
onClick={() => setRequestOpen(true)}
>
<MessageSquareText size={16} />
{t("login.requestAccess")}
</Button>
)}
<AccessRequestModal open={requestOpen} onClose={() => setRequestOpen(false)} />
{recoveryOn && (
<button onClick={() => setRecoverOpen(true)} className="mt-2 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline">
{t("login.forgot")}
</button>
)}
<RecoverDialog open={recoverOpen} onClose={() => setRecoverOpen(false)} from={recoveryFrom} />
<div className="mt-3 flex justify-center">
<LangPicker />
</div>
<ul className="mt-4 grid grid-cols-1 gap-2 border-t border-stone-200/70 pt-4 sm:grid-cols-2 dark:border-zinc-800">
{PERKS.map((p) => (
<li key={p.text} className="flex items-center gap-2 text-xs opacity-70">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<p.icon size={13} />
</span>
{p.text}
</li>
))}
</ul>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
return (
<div className="relative">
{lang && (
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
)}
<div className="absolute right-2.5 top-2">
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
</pre>
</div>
);
}
export function Markdown({ text }: { text: string }) {
return (
<div className="md space-y-3 text-[15px] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre({ children }) {
// react-markdown nests <code> inside <pre>; render our block instead.
const child = Array.isArray(children) ? children[0] : children;
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
const className = props?.className || "";
const lang = className.replace("language-", "");
const code = String(props?.children ?? "").replace(/\n$/, "");
return <CodeBlock code={code} lang={lang} />;
},
code({ children }) {
return <code>{children}</code>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { ChevronDown, FileText } from "lucide-react";
import { cn } from "../lib/cn.ts";
/**
* User message content with attached files folded away.
*
* Text/OCR attachments travel inlined in the message as:
* [attached text file: foo.txt]
* ```text
* <whole file content, up to 500KB>
* ```
* Rendering that raw blows the bubble off-screen, so each block renders as
* a collapsed attachment row (header + expand chevron, no i18n needed) with
* the body capped to a scrollable pane when expanded.
*/
const ATTACH_RE = /^(\[.*?\])\n```text\n([\s\S]*?)\n```/gm;
function AttachmentBlock({ header, body, tone }: { header: string; body: string; tone: "accent" | "plain" }) {
const [open, setOpen] = useState(false);
const label = header.startsWith("[") && header.endsWith("]") ? header.slice(1, -1) : header;
return (
<span
className={cn(
"mt-2 block overflow-hidden rounded-2xl text-left",
tone === "accent" ? "bg-black/25 dark:bg-black/30" : "bg-stone-200/70 dark:bg-zinc-800",
)}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] font-medium"
>
<FileText size={14} className="shrink-0 opacity-80" />
<span className="min-w-0 flex-1 truncate">{label}</span>
<ChevronDown
size={14}
className={cn("shrink-0 opacity-80 transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<pre
className={cn(
"max-h-72 overflow-auto whitespace-pre-wrap break-words border-t px-3 py-2 font-mono text-[12.5px] leading-relaxed",
tone === "accent" ? "border-white/10" : "border-stone-300/70 dark:border-zinc-700",
)}
>
{body}
</pre>
)}
</span>
);
}
export function UserMessageContent({ content, tone = "accent" }: { content: string; tone?: "accent" | "plain" }) {
const parts: React.ReactNode[] = [];
let last = 0;
let key = 0;
ATTACH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ATTACH_RE.exec(content)) !== null) {
if (m.index > last) parts.push(<span key={key++}>{content.slice(last, m.index)}</span>);
parts.push(<AttachmentBlock key={key++} header={m[1]!} body={m[2] ?? ""} tone={tone} />);
last = m.index + m[0].length;
}
if (last < content.length) parts.push(<span key={key++}>{content.slice(last)}</span>);
if (parts.length === 0) return <>{content}</>;
return <>{parts}</>;
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { Bot, Undo2 } from "lucide-react";
import { api, type PolicyModel } from "../lib/api.ts";
import { OllamaModelsModal } from "./AdminPanel.tsx";
import { Button, Input, Spinner, Switch } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
/** Admin models section: per-model kill-switch + rate limits, Ollama library. */
export function ModelsPanel() {
const [models, setModels] = useState<PolicyModel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [ollamaOpen, setOllamaOpen] = useState(false);
const { t } = useT();
async function refresh(force = false) {
setError("");
try {
const res = await api.adminModelPolicy(force);
setModels(res.models);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function apply(updated: PolicyModel) {
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)));
}
const groups = (() => {
const byGroup = new Map<string, PolicyModel[]>();
for (const m of models) {
const g = m.group ?? m.driver;
const list = byGroup.get(g) ?? [];
list.push(m);
byGroup.set(g, list);
}
return [...byGroup.entries()];
})();
return (
<div className="space-y-5">
<section>
<div className="mb-2 flex items-center gap-2">
<p className="text-sm font-medium">{t("models.title")}</p>
<button
onClick={() => refresh(true)}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("models.refresh")}
aria-label={t("models.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
<p className="mb-3 text-sm opacity-60">{t("models.desc")}</p>
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!loading && models.length === 0 && !error && (
<p className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
{t("models.empty")}
</p>
)}
{groups.map(([group, list]) => (
<div key={group} className="mb-4">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{group}</p>
<ul className="space-y-2">
{list.map((m) => (
<ModelRow key={m.id} model={m} onChanged={apply} onError={setError} />
))}
</ul>
</div>
))}
</section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">{t("models.ollamaTitle")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("models.ollamaDesc")}</p>
<div className="mt-3">
<Button
variant="secondary"
size="sm"
onClick={() => setOllamaOpen(true)}
>
<Bot size={14} /> {t("ollama.models")}
</Button>
</div>
</section>
<OllamaModelsModal
open={ollamaOpen}
onClose={() => {
setOllamaOpen(false);
refresh();
}}
/>
</div>
);
}
function ModelRow({ model, onChanged, onError }: {
model: PolicyModel;
onChanged: (m: PolicyModel) => void;
onError: (msg: string) => void;
}) {
const { t } = useT();
const [hourly, setHourly] = useState(model.hourly === 0 ? "" : String(model.hourly));
const [daily, setDaily] = useState(model.daily === 0 ? "" : String(model.daily));
const [busy, setBusy] = useState(false);
useEffect(() => {
setHourly(model.hourly === 0 ? "" : String(model.hourly));
setDaily(model.daily === 0 ? "" : String(model.daily));
}, [model.hourly, model.daily]);
async function patch(p: { enabled?: boolean; hourly?: number; daily?: number }) {
setBusy(true);
try {
const res = await api.adminModelPolicyPatch(model.id, p);
onChanged(res.model);
onError("");
} catch (err) {
onError(err instanceof Error ? err.message : t("models.saveFailed"));
} finally {
setBusy(false);
}
}
function commitNumber(kind: "hourly" | "daily", raw: string) {
const v = raw.trim() === "" ? 0 : Number(raw);
if (!Number.isInteger(v) || v < 0 || v > 1000000) {
if (kind === "hourly") setHourly(model.hourly === 0 ? "" : String(model.hourly));
else setDaily(model.daily === 0 ? "" : String(model.daily));
return;
}
if (v === model[kind]) return;
void patch({ [kind]: v });
}
return (
<li
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 dark:border-zinc-800 dark:bg-zinc-900",
!model.enabled && "opacity-60",
)}
>
<span className="min-w-0 flex-1 basis-44">
<span className="block truncate text-sm font-medium">{model.label || model.id}</span>
<span className="block truncate font-mono text-xs opacity-50">{model.id}</span>
</span>
<label className="flex items-center gap-1.5 text-xs opacity-80">
<Input
type="number"
min={0}
max={1000000}
value={hourly}
disabled={busy}
placeholder={t("models.unlimited")}
aria-label={t("models.hourly")}
onChange={(e) => setHourly(e.target.value)}
onBlur={(e) => commitNumber("hourly", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="w-24"
/>
{t("models.hourly")}
</label>
<label className="flex items-center gap-1.5 text-xs opacity-80">
<Input
type="number"
min={0}
max={1000000}
value={daily}
disabled={busy}
placeholder={t("models.unlimited")}
aria-label={t("models.daily")}
onChange={(e) => setDaily(e.target.value)}
onBlur={(e) => commitNumber("daily", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="w-24"
/>
{t("models.daily")}
</label>
<Switch
label={t("models.enabled")}
checked={model.enabled}
disabled={busy}
onChange={(v) => void patch({ enabled: v })}
/>
</li>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react";
import { api } from "../lib/api.ts";
import { useT } from "../lib/i18n.ts";
import { navigate } from "../lib/route.ts";
import { Button, CopyButton, Field, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
const PREFILL_KEY = "ka_login_prefill";
/** Full-page self-registration (/register). No boot splash on this route. */
export function RegisterPage() {
const { t } = useT();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [key, setKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [registrationOn, setRegistrationOn] = useState(true);
useEffect(() => {
api.methods().then((m) => setRegistrationOn(m.registration)).catch(() => {});
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const res = await api.register({
username: username.trim().toLowerCase(),
displayName: displayName.trim() || undefined,
email: email.trim() || undefined,
});
setKey(res.key);
} catch (err) {
setError(err instanceof Error ? err.message : t("dlg.regFailed"));
} finally {
setBusy(false);
}
}
function goLogin() {
const u = username.trim().toLowerCase();
if (key && u) {
try {
sessionStorage.setItem(PREFILL_KEY, JSON.stringify({ username: u, key }));
} catch {
// private mode — the login form just stays empty
}
}
navigate("/login");
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-md">
<div className="rounded-[2rem] border border-white/60 bg-white/80 px-6 py-6 shadow-2xl shadow-accent-900/5 backdrop-blur-xl sm:px-8 dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-1 flex flex-col items-center text-center">
<FlowerMark size={52} dynamic={false} className="mb-2 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("dlg.registerTitle")}</h1>
</div>
{key ? (
<div className="mt-4 space-y-3">
<p className="text-center text-sm opacity-80">
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
<CopyButton text={key} />
</div>
<Button variant="gradient" size="lg" className="w-full" onClick={goLogin}>
{t("dlg.regContinue")} <ArrowRight size={17} />
</Button>
</div>
) : (
<form onSubmit={submit} className="mt-4 space-y-2.5">
<div className="grid grid-cols-2 gap-2.5">
<Field label={t("common.username")}>
<Input
variant="soft"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
maxLength={32}
autoComplete="username"
required
/>
</Field>
<Field label={t("common.displayName")}>
<Input
variant="soft"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Alice"
maxLength={60}
autoComplete="nickname"
/>
</Field>
</div>
<Field label={t("dlg.emailOpt")}>
<Input
variant="soft"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
inputMode="email"
autoComplete="email"
/>
</Field>
{error && (
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={busy || !registrationOn}
title={registrationOn ? undefined : t("login.regTipOff")}
>
{busy ? <Spinner /> : (<><UserPlus size={16} /> {t("login.register")}</>)}
</Button>
{!registrationOn && (
<p className="text-center text-sm opacity-60">{t("login.regTipOff")}</p>
)}
</form>
)}
<button
onClick={() => navigate("/login")}
className="mt-3 inline-flex w-full items-center justify-center gap-1.5 text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"
>
<ArrowLeft size={15} />
{t("common.backToLogin")}
</button>
<div className="mt-3 flex justify-center">
<LangPicker />
</div>
</div>
</div>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
import { api, type Report, type ReportStatus } from "../lib/api.ts";
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<ReportStatus, { cls: string }> = {
open: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
resolved: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
dismissed: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
};
type Filter = "all" | ReportStatus;
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
export function ReportsPanel() {
const [reports, setReports] = useState<Report[]>([]);
const [filter, setFilter] = useState<Filter>("open");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const { t } = useT();
const listBusy = useRef(false);
async function refresh(select?: string) {
if (listBusy.current) return;
listBusy.current = true;
try {
const res = await api.adminReportList();
setReports(res.reports);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
listBusy.current = false;
setLoading(false);
}
}
useEffect(() => {
refresh();
// Live list: new reports pop without manual refresh.
const id = setInterval(() => {
if (!document.hidden) refresh();
}, 15000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visible = reports.filter((r) => filter === "all" || r.status === filter);
const openCount = reports.filter((r) => r.status === "open").length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{FILTERS.map((f) => {
const n = f === "all" ? reports.length : reports.filter((r) => r.status === f).length;
return (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"rounded-full px-3.5 py-1.5 text-xs font-medium capitalize transition",
filter === f
? "bg-stone-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
)}
>
{t(`reports.filter.${f}` as StringKey)} · {n}
</button>
);
})}
<button
onClick={() => refresh()}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("reports.refresh")}
aria-label={t("reports.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!loading && visible.length === 0 && (
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
<Inbox size={16} />
{openCount > 0 ? t("reports.emptyFiltered") : t("reports.empty")}
</p>
)}
<ul className="space-y-2">
{visible.map((r) => (
<li key={r.id}>
<button
onClick={() => setSelectedId((cur) => (cur === r.id ? null : r.id))}
className={cn(
"flex w-full items-center gap-3 rounded-3xl border p-4 text-left transition",
selectedId === r.id
? "border-accent-500/50 bg-accent-500/5"
: "border-stone-200/70 bg-white hover:border-stone-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
)}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-stone-200/70 dark:bg-zinc-800">
<Flag size={15} className="opacity-70" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{t(`report.reason.${r.reason}` as StringKey)}
<span className="font-normal opacity-50"> · @{r.reporter_name} · {new Date(r.created_at).toLocaleString()}</span>
</span>
<span className="block truncate text-xs opacity-60">{r.content || t(`report.reason.${r.reason}` as StringKey)}</span>
</span>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
{t(`reports.status.${r.status}` as StringKey)}
</span>
{r.reporter_shadowbanned === 1 && (
<span className="shrink-0 rounded-full bg-stone-500/10 px-2.5 py-1 text-xs font-medium opacity-70">
{t("reports.unreliable")}
</span>
)}
</button>
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
</li>
))}
</ul>
</div>
);
}
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
const [note, setNote] = useState(report.admin_note);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmBan, setConfirmBan] = useState<boolean | null>(null);
const { t } = useT();
const closed = report.status === "resolved" || report.status === "dismissed";
const isBanned = report.reporter_shadowbanned === 1;
useEffect(() => {
setNote(report.admin_note);
}, [report.id, report.admin_note]);
async function setStatus(status: ReportStatus) {
setBusy(true);
setError("");
try {
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
setBusy(false);
}
}
async function toggleBan(shadowbanned: boolean) {
setBusy(true);
setError("");
try {
await api.adminShadowban(report.reporter_id, shadowbanned);
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
{report.prompt && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wider opacity-50">{t("reports.prompt")}</p>
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 text-sm dark:border-zinc-800 dark:bg-zinc-900">
{report.prompt}
</div>
</div>
)}
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wider opacity-50">{t("reports.response")}</p>
<div className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 text-sm dark:border-zinc-800 dark:bg-zinc-900">
{report.content || <span className="opacity-50">{t("reports.noContent")}</span>}
</div>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs opacity-70">
<span>{t("reports.model")}: <span className="font-medium opacity-100">{report.model || "—"}</span></span>
<span>{t("reports.reporter")}: <span className="font-medium opacity-100">@{report.reporter_name}</span></span>
{report.conversation_id && (
<span>{t("reports.conversation")}: <code className="font-mono">{report.conversation_id.slice(0, 8)}…</code></span>
)}
{report.details && (
<span className="w-full">{t("reports.detailsGiven")}: <span className="opacity-100">{report.details}</span></span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setConfirmBan(!isBanned)}>
{isBanned ? <Eye size={14} /> : <EyeOff size={14} />}
{isBanned ? t("reports.unshadowban") : t("reports.shadowban")}
</Button>
{isBanned && <span className="text-xs opacity-60">{t("reports.unreliable")}</span>}
</div>
<ConfirmDialog
open={confirmBan !== null}
onClose={() => setConfirmBan(null)}
onConfirm={() => {
const v = confirmBan;
setConfirmBan(null);
if (v !== null) void toggleBan(v);
}}
title={t(confirmBan === false ? "reports.unshadowbanTitle" : "reports.shadowbanTitle")}
message={t(confirmBan === false ? "reports.unshadowbanMsg" : "reports.shadowbanMsg", { user: report.reporter_name })}
confirmLabel={t(confirmBan === false ? "reports.unshadowban" : "reports.shadowban")}
/>
{!closed ? (
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="min-w-48 flex-1">
<Field label={t("reports.noteLabel")}>
<Input value={note} onChange={(e) => setNote(e.target.value)} placeholder={t("reports.notePh")} maxLength={1000} />
</Field>
</div>
<div className="flex gap-2">
{report.status === "open" && (
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
<Search size={14} /> {t("reports.btnReviewing")}
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => setStatus("resolved")}>
<CheckCircle2 size={14} /> {t("reports.btnResolve")}
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("dismissed")} className="!text-red-600 dark:!text-red-400">
<XCircle size={14} /> {t("reports.btnDismiss")}
</Button>
</div>
</div>
) : (
<p className="flex items-center gap-2 text-xs opacity-60">
<Clock size={13} /> {t("reports.closedNote", { status: t(`reports.status.${report.status}` as StringKey).toLowerCase() })}
{report.admin_note && <> {t("reports.noteWord")}: <em>{report.admin_note}</em></>}
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { KeyRound, MailWarning, Sparkles } from "lucide-react";
import { api } from "../lib/api.ts";
import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
type State =
| { kind: "loading" }
| { kind: "invalid" }
| { kind: "ready"; username: string }
| { kind: "working" }
| { kind: "done"; key: string }
| { kind: "error"; message: string };
/** Public one-time key reset page: /reset/:token */
export function ResetPage({ token }: { token: string }) {
const { t } = useT();
const [state, setState] = useState<State>({ kind: "loading" });
useEffect(() => {
api.resetCheck(token)
.then((r) => setState({ kind: "ready", username: r.username }))
.catch(() => setState({ kind: "invalid" }));
}, [token]);
async function consume() {
setState({ kind: "working" });
try {
const r = await api.resetConsume(token);
setState({ kind: "done", key: r.key });
} catch (err) {
setState({ kind: "error", message: err instanceof Error ? err.message : t("reset.failed") });
}
}
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
{state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("reset.checking")}</p>}
{state.kind === "invalid" && (
<div className="space-y-3">
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> {t("reset.invalid")}</p>
<p className="text-sm opacity-70">{t("reset.invalidSub")}</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
</div>
)}
{state.kind === "ready" && (
<div className="space-y-4">
<h1 className="text-xl font-bold">{t("reset.readyTitle", { user: state.username })}</h1>
<p className="text-sm opacity-70">{t("reset.readySub")}</p>
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
<KeyRound size={16} /> {t("reset.issue")}
</Button>
</div>
)}
{state.kind === "working" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("reset.issuing")}</p>}
{state.kind === "done" && (
<div className="space-y-4">
<h1 className="flex items-center justify-center gap-2 text-xl font-bold"><Sparkles size={18} className="text-accent-500" /> {t("reset.doneTitle")}</h1>
<p className="text-sm opacity-70">{t("reset.doneSub")}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{state.key}</code>
<CopyButton text={state.key} />
</div>
<Button size="lg" className="w-full" onClick={() => (window.location.href = "/")}>{t("reset.goLogin")}</Button>
</div>
)}
{state.kind === "error" && (
<div className="space-y-3">
<p className="font-semibold text-red-600 dark:text-red-400">{t("reset.failed")}</p>
<p className="text-sm opacity-70">{state.message}</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>{t("common.backToLogin")}</Button>
</div>
)}
</div>
</div>
);
}
+402
View File
@@ -0,0 +1,402 @@
import { useEffect, useState } from "react";
import {
CheckCircle2,
Clock,
MailWarning,
MessageSquareText,
Search,
SendHorizontal,
XCircle,
} from "lucide-react";
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import {
Button,
CopyButton,
Field,
FlowerMark,
Input,
Modal,
Spinner,
} from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<
AccessRequest["status"],
{ cls: string; icon: typeof Clock }
> = {
pending: {
cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
icon: Clock,
},
reviewing: {
cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400",
icon: Search,
},
accepted: {
cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400",
icon: CheckCircle2,
},
refused: {
cls: "bg-red-500/10 text-red-600 dark:text-red-400",
icon: XCircle,
},
};
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
const { t } = useT();
const s = STATUS_STYLE[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
s.cls,
)}
>
<s.icon size={13} />
{t(`review.status.${status}` as StringKey)}
</span>
);
}
/** Public ticket page: /review/:id — argue your case, follow the decision. */
export function ReviewPage({ ticketId }: { ticketId: string }) {
const [request, setRequest] = useState<AccessRequest | null>(null);
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(true);
const [invalid, setInvalid] = useState(false);
const { t } = useT();
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
async function load() {
try {
const res = await api.accessTicket(ticketId);
setRequest(res.request);
setMessages(res.messages);
} catch {
setInvalid(true);
} finally {
setLoading(false);
}
}
/** Silent resync after a (re)connect — never flips to the error state. */
async function refreshSilent() {
try {
const res = await api.accessTicket(ticketId);
setRequest(res.request);
setMessages(res.messages);
} catch {
// Transient failure: keep last known state.
}
}
useEffect(() => {
load();
// Live updates over WebSocket: admin replies and the decision arrive
// instantly; resync on (re)connect covers events missed while offline.
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== ticketId) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
} else if (evt.type === "access_status") {
setRequest(evt.request);
}
},
onSync: () => {
refreshSilent();
},
});
return close;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]);
async function send(e: React.FormEvent) {
e.preventDefault();
const body = draft.trim();
if (!body || sending) return;
setSending(true);
setError("");
try {
const res = await api.accessReply(ticketId, body);
setMessages((prev) => [...prev, res.message]);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
} finally {
setSending(false);
}
}
const closed =
request?.status === "accepted" || request?.status === "refused";
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40 sm:p-6">
<div className="w-full max-w-xl rounded-[2rem] border border-white/60 bg-white/80 p-6 shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80 sm:p-8">
<FlowerMark size={48} dynamic={false} className="mx-auto mb-3" />
<h1 className="text-center font-serif text-2xl font-bold tracking-tight">
{t("review.title")}
</h1>
{loading && (
<p className="mt-4 flex items-center justify-center gap-2 text-sm opacity-60">
<Spinner size={15} /> {t("review.loadingTicket")}
</p>
)}
{invalid && (
<div className="mt-4 space-y-3 text-center">
<p className="flex items-center justify-center gap-2 font-semibold">
<MailWarning size={18} className="text-red-500" />{" "}
{t("review.notFound")}
</p>
<p className="text-sm opacity-70">{t("review.notFoundSub")}</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>
{t("common.backToLogin")}
</Button>
</div>
)}
{request && (
<div className="mt-4 space-y-4">
<div className="flex flex-wrap items-center justify-center gap-2">
<StatusBadge status={request.status} />
<span className="text-sm opacity-70">@{request.username}</span>
</div>
{request.reason && (
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-center text-sm dark:bg-zinc-800">
<span className="font-medium">{t("review.adminNote")} </span>
{request.reason}
</p>
)}
{request.status === "accepted" && (
<>
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
{t("review.acceptedHint")}
</p>
<Button
size="sm"
className="w-full"
onClick={() => (window.location.href = "/")}
>
{t("common.backToLogin")}
</Button>
</>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
{messages.map((m) => (
<div
key={m.id}
className={cn(
"flex",
m.author === "admin" ? "justify-start" : "justify-end",
)}
>
<div
className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2 text-sm",
m.author === "admin"
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
: "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950",
)}
>
<p className="mb-0.5 text-[11px] font-medium opacity-70">
{m.author === "admin"
? t("review.admin")
: t("review.you")}
</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</div>
</div>
))}
</div>
{closed ? (
<p className="text-center text-xs opacity-60">
{t("review.closedHint")}
</p>
) : (
<form onSubmit={send} className="space-y-2">
<div className="flex items-end gap-2">
<textarea
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t("review.replyPh")}
aria-label={t("review.replyAria")}
className="max-h-40 flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!draft.trim() || sending}
aria-label={t("chat.send")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
{sending ? (
<Spinner size={16} />
) : (
<SendHorizontal size={16} />
)}
</button>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<p className="text-center text-xs opacity-60">
{t("review.mailHint")}
</p>
</form>
)}
</div>
)}
</div>
</div>
);
}
/** Wishlist form: reserve a username + email with a motivation message. */
export function AccessRequestModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { t } = useT();
const [done, setDone] = useState<{
reviewUrl: string;
username: string;
} | null>(null);
useEffect(() => {
if (open) {
setUsername("");
setEmail("");
setMessage("");
setError("");
setDone(null);
}
}, [open]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const res = await api.accessRequest({
username: username.trim().toLowerCase(),
email: email.trim(),
message: message.trim(),
});
setDone({ reviewUrl: res.reviewUrl, username: res.request.username });
} catch (err) {
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={t("review.requestTitle")}
icon={MessageSquareText}
>
{done ? (
<div className="space-y-4 text-center">
<p className="flex items-center justify-center gap-2 font-semibold text-accent-700 dark:text-accent-400">
<CheckCircle2 size={18} /> {t("review.received")}
</p>
<p className="text-sm opacity-70">
{t("review.reserved", { user: done.username })}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-left text-xs dark:bg-zinc-800">
{done.reviewUrl}
</code>
<CopyButton text={done.reviewUrl} label={t("review.copyLink")} />
</div>
<Button
size="sm"
className="w-full"
onClick={() =>
(window.location.href = done.reviewUrl.replace(
window.location.origin,
"",
))
}
>
{t("review.openTicket")}
</Button>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm opacity-70">{t("review.requestIntro")}</p>
<Field label={t("common.username")}>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
required
minLength={2}
maxLength={32}
/>
</Field>
<Field label={t("review.emailLabel")} hint={t("review.emailHint")}>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
inputMode="email"
required
/>
</Field>
<Field label={t("review.whyLabel")} hint={t("review.whyHint")}>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t("review.whyPh")}
required
minLength={10}
maxLength={2000}
rows={4}
className="max-h-60 w-full resize-y rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
</Field>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
type="button"
onClick={onClose}
>
{t("common.cancel")}
</Button>
<Button size="sm" type="submit" disabled={busy}>
{busy ? <Spinner size={15} /> : t("review.sendRequest")}
</Button>
</div>
</form>
)}
</Modal>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { Archive, Bot, CloudOff, Globe, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts";
import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { UserMessageContent } from "./MessageContent.tsx";
import { useT } from "../lib/i18n.ts";
export function SharePage({ publicId }: { publicId: string }) {
const { t } = useT();
const [chat, setChat] = useState<SharedChat | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.publicShare(publicId)
.then(setChat)
.catch((err) => setError(err instanceof Error ? err.message : "Not found"));
}, [publicId]);
return (
<div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
<FlowerMark size={36} dynamic={false} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{chat && (
<p className="flex flex-wrap items-center gap-1.5 text-xs opacity-60">
<Globe size={11} /> by {chat.authorName} · {new Date(chat.sharedAt).toLocaleDateString()}
{chat.model && <> · {chat.model.split(":").pop()}</>}
{chat.archived && (
<span className="inline-flex items-center gap-1 rounded-full bg-stone-200/70 px-2 py-0.5 font-medium opacity-100 dark:bg-zinc-800">
<Archive size={11} /> Archived
</span>
)}
</p>
)}
</div>
<a href="/" className="rounded-full bg-accent-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950">
{t("share.open")}
</a>
</div>
</header>
<main className="mx-auto w-full max-w-3xl space-y-5 px-4 py-6">
{error && (
<div className="mx-auto max-w-md py-10 text-center">
<div className="rounded-[2rem] border border-stone-200/70 bg-white p-8 shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<span className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-full bg-stone-200/70 text-stone-500 dark:bg-zinc-800 dark:text-zinc-400">
<CloudOff size={28} />
</span>
<h1 className="bg-gradient-to-br from-stone-900 via-stone-700 to-accent-700 bg-clip-text font-serif text-4xl font-black italic tracking-tight text-transparent dark:from-zinc-100 dark:via-zinc-300 dark:to-accent-400">{t("share.gone")}</h1>
<p className="mt-2 text-sm opacity-70">
{t("share.goneSub")}
{error && error.toLowerCase() !== "not found" ? ` (${error})` : ""}
</p>
<div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
<a href="/" className="rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-accent-500 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400">
{t("share.open")}
</a>
<button
onClick={() => window.history.length > 1 ? window.history.back() : (window.location.href = "/")}
className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-medium transition hover:bg-stone-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
{t("share.back")}
</button>
</div>
</div>
<FlowerMark size={28} dynamic={false} className="mx-auto mt-6 opacity-40" />
</div>
)}
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">{t("share.loading")}</p>}
{chat?.messages.map((m, i) => (
<div key={i} className="flex gap-3">
{m.role === "user"
? <Avatar name={chat.authorName} url={chat.authorAvatarUrl || undefined} size={32} />
: <AssistantAvatar size={32} />}
<div className="min-w-0 max-w-[90%] flex-1">
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
{m.role === "user" ? chat.authorName : "KisAssistant"}
</div>
{m.role === "user" ? (
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={m.content} />
</div>
)}
</div>
</div>
))}
</main>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { useEffect, useRef, useState } from "react";
import {
Archive,
LayoutDashboard,
MessageSquarePlus,
MoreVertical,
PanelLeftClose,
Pencil,
Search,
Settings,
Share2,
Tag,
Trash2,
X,
} from "lucide-react";
import type { Conversation, User } from "../lib/types.ts";
import { api } from "../lib/api.ts";
import { useT } from "../lib/i18n.ts";
import { Avatar, Button, ContextMenu, FlowerMark, IconButton } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
interface MenuState {
x: number;
y: number;
conv: Conversation;
}
export function Sidebar({
user,
convs,
activeId,
collapsed,
onToggle,
onNew,
onSelect,
onRename,
onTopic,
onShare,
onArchive,
onDelete,
onOpenSettings,
onOpenAdmin,
onLogout,
mobileOpen,
onCloseMobile,
}: {
user: User;
convs: Conversation[];
activeId: string | null;
collapsed: boolean;
onToggle: () => void;
onNew: () => void;
onSelect: (id: string) => void;
onRename: (conv: Conversation) => void;
onTopic: (conv: Conversation) => void;
onShare: (conv: Conversation) => void;
onArchive: (conv: Conversation) => void;
onDelete: (conv: Conversation) => void;
onOpenSettings: () => void;
onOpenAdmin: () => void;
onLogout: () => void;
mobileOpen: boolean;
onCloseMobile: () => void;
}) {
const { t } = useT();
const [menu, setMenu] = useState<MenuState | null>(null);
const [query, setQuery] = useState("");
const [results, setResults] = useState<(Conversation & { snippet: string | null; snippetRole: string | null })[] | null>(null);
const [searching, setSearching] = useState(false);
const touchX = useRef<number | null>(null);
// Long-press (mobile right-click): open the context menu, suppress the tap.
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pressPos = useRef<{ x: number; y: number } | null>(null);
const suppressClick = useRef(false);
function openMenuAt(x: number, y: number, conv: Conversation) {
setMenu({ x, y, conv });
}
function openMenu(e: React.MouseEvent, conv: Conversation) {
e.preventDefault();
e.stopPropagation();
openMenuAt(e.clientX, e.clientY, conv);
}
function cancelPress() {
if (pressTimer.current) clearTimeout(pressTimer.current);
pressTimer.current = null;
pressPos.current = null;
}
// Debounced search over titles, topics and old prompts/replies.
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults(null);
setSearching(false);
return;
}
setSearching(true);
const t = setTimeout(() => {
api.searchConvs(q)
.then((res) => setResults(res.conversations))
.catch(() => setResults([]))
.finally(() => setSearching(false));
}, 250);
return () => clearTimeout(t);
}, [query]);
if (collapsed && !mobileOpen) return null;
return (
<>
{mobileOpen && (
<div className="fixed inset-0 z-30 bg-black/40 md:hidden" onClick={onCloseMobile} aria-hidden="true" />
)}
<aside
onTouchStart={(e) => { touchX.current = e.touches[0]!.clientX; }}
onTouchEnd={(e) => {
if (touchX.current === null) return;
const dx = e.changedTouches[0]!.clientX - touchX.current;
touchX.current = null;
if (dx < -60) onCloseMobile(); // swipe left closes the drawer
}}
className={cn(
"fixed inset-y-0 left-0 z-40 flex h-full w-72 shrink-0 flex-col gap-2 border-r border-stone-200 bg-white p-3 shadow-2xl transition-transform duration-200",
"md:static md:z-auto md:shadow-none dark:border-zinc-800 dark:bg-zinc-900 md:dark:bg-zinc-900/70 md:bg-white/70 md:backdrop-blur",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"md:translate-x-0",
collapsed && "md:hidden",
)}
>
<div className="flex items-center gap-2 px-2 pb-1 pt-0.5">
<FlowerMark size={36} dynamic={false} />
<span className="font-serif text-xl font-bold tracking-tight">KisAssistant</span>
</div>
<div className="flex items-center gap-1">
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
<MessageSquarePlus size={16} />
{t("sidebar.newChat")}
</Button>
<IconButton title={t("sidebar.collapse")} onClick={onToggle} className="hidden md:inline-flex">
<PanelLeftClose size={18} />
</IconButton>
<IconButton title={t("sidebar.closeDrawer")} onClick={onCloseMobile} className="md:hidden">
<X size={18} />
</IconButton>
</div>
<div className="relative">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sidebar.searchPh")}
aria-label={t("sidebar.searchAria")}
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
/>
{query && (
<button
aria-label={t("sidebar.clearSearch")}
onClick={() => setQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 space-y-1 overflow-y-auto py-1">
{results !== null ? (
<>
{searching && <p className="px-3 py-4 text-center text-sm opacity-50">{t("sidebar.searching")}</p>}
{!searching && results.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">{t("sidebar.noMatch", { q: query.trim() })}</p>
)}
{results.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => { setQuery(""); onSelect(c.id); }}
onKeyDown={(e) => { if (e.key === "Enter") { setQuery(""); onSelect(c.id); } }}
className={cn(
"group cursor-pointer select-none rounded-2xl px-3 py-2.5 text-sm transition",
c.id === activeId
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<span className="block truncate">{c.title}</span>
{c.snippet && (
<span className="mt-0.5 block truncate text-xs opacity-60">
{c.snippetRole === "user" ? t("sidebar.youPrefix") : ""}{c.snippet}
</span>
)}
</div>
))}
</>
) : (
<>
{convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">{t("sidebar.emptyA")}<br />{t("sidebar.emptyB")}</p>
)}
{convs.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
onSelect(c.id);
}}
onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}
onContextMenu={(e) => openMenu(e, c)}
onTouchStart={(e) => {
const t = e.touches[0]!;
pressPos.current = { x: t.clientX, y: t.clientY };
pressTimer.current = setTimeout(() => {
suppressClick.current = true;
openMenuAt(t.clientX, t.clientY, c);
}, 550);
}}
onTouchMove={(e) => {
const p = pressPos.current;
const t = e.touches[0]!;
if (p && Math.hypot(t.clientX - p.x, t.clientY - p.y) > 10) cancelPress();
}}
onTouchEnd={cancelPress}
className={cn(
"group flex cursor-pointer select-none items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition [-webkit-touch-callout:none]",
c.id === activeId
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{c.title}</span>
{c.topic && (
<span className="mt-0.5 flex items-center gap-1 text-xs opacity-60">
<Tag size={11} />
<span className="truncate">{c.topic}</span>
</span>
)}
</span>
<button
aria-label={t("sidebar.convOptions")}
onClick={(e) => openMenu(e, c)}
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
>
<MoreVertical size={15} />
</button>
</div>
))}
</>
)}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
items={[
{ icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
{ icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
{ icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
{ icon: Archive, label: t("sidebar.archive"), onClick: () => onArchive(menu.conv) },
{ icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
]}
/>
)}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title={t("sidebar.settingsTip")}>
<Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span>
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span>
</span>
<Settings size={16} className="mr-2 shrink-0 opacity-50" />
</button>
<div className="flex gap-1.5">
{user.isAdmin && (
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title={t("sidebar.adminCenterTip")}>
<LayoutDashboard size={15} />
{t("sidebar.adminCenter")}
</Button>
)}
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
{t("sidebar.logout")}
</Button>
</div>
</div>
</aside>
</>
);
}
File diff suppressed because it is too large Load Diff
+614
View File
@@ -0,0 +1,614 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
import { LANGS, setLang, useT, type LangId } from "../lib/i18n.ts";
/* ---------- Button ---------- */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "gradient" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg" | "icon";
};
export function Button({ variant = "primary", size = "md", type = "button", className, ...props }: ButtonProps) {
return (
<button
type={type}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" && "px-3.5 py-1.5 text-sm",
size === "md" && "px-5 py-2.5 text-sm",
size === "lg" && "px-6 py-3 text-base",
size === "icon" && "h-9 w-9",
variant === "primary" && "bg-accent-600 text-white shadow-sm hover:bg-accent-500 dark:bg-accent-500 dark:hover:bg-accent-400 dark:text-zinc-950",
variant === "gradient" && "bg-gradient-to-r from-accent-600 to-teal-500 text-white shadow-lg shadow-accent-600/25 hover:from-accent-500 hover:to-teal-400 dark:from-accent-500 dark:to-teal-400 dark:text-zinc-950",
variant === "secondary" && "border border-stone-200 bg-white hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800",
variant === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
className,
)}
{...props}
/>
);
}
export function IconButton({ className, title, ...props }: ButtonProps & { title: string }) {
return <Button variant="ghost" size="icon" title={title} aria-label={title} className={cn("rounded-full", className)} {...props} />;
}
/* ---------- Inputs ---------- */
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium opacity-80">{label}</span>
{children}
{hint && <span className="mt-1 block text-xs opacity-60">{hint}</span>}
</label>
);
}
export function Input({ variant = "default", className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { variant?: "default" | "soft" }) {
return (
<input
className={cn(
"w-full text-sm outline-none transition placeholder:text-stone-400 dark:placeholder:text-zinc-500",
variant === "default" &&
"rounded-2xl border border-stone-200 bg-white px-4 py-2.5 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20 dark:border-zinc-700 dark:bg-zinc-900",
// soft: onboarding-style pill field, blends into rounded cards
variant === "soft" &&
"rounded-full border border-transparent bg-stone-100 px-5 py-3 focus:border-accent-500/60 focus:bg-white focus:ring-4 focus:ring-accent-500/15 dark:bg-zinc-800/80 dark:focus:bg-zinc-900",
className,
)}
{...props}
/>
);
}
/* ---------- Picker (framework-styled dropdown, replaces native <select>) ---------- */
export interface PickerOption {
value: string;
label: string;
hint?: string;
}
export interface PickerGroup {
group: string;
options: PickerOption[];
}
export function Picker({
value,
onChange,
options,
groups,
placeholder,
ariaLabel,
disabled,
icon: Icon,
className,
align = "right",
}: {
value: string;
onChange: (v: string) => void;
options?: PickerOption[];
groups?: PickerGroup[];
placeholder?: string;
ariaLabel: string;
disabled?: boolean;
icon?: LucideIcon;
className?: string;
align?: "left" | "right";
}) {
const { t } = useT();
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 208 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
const shownPlaceholder = placeholder ?? t("common.select");
// Position the popover in viewport coords (rendered in a portal so no
// ancestor overflow — e.g. Modal's overflow-y-auto — can clip it).
// Re-measured live so the popover stays anchored to its field.
const updatePos = useCallback(() => {
const el = btnRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return false; // not laid out yet
const width = Math.min(Math.max(r.width, 208), 288);
let left = align === "right" ? r.right - width : r.left;
left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
// Use the popover's real height (not the max-h-72 cap) so short lists
// (e.g. 3 theme options) stay glued under the button instead of flipping
// above with a visible gap.
const popH = Math.min(popRef.current?.offsetHeight || 288, 288);
const below = r.bottom + 8;
const top = below + popH > window.innerHeight && r.top - popH - 8 > 8
? Math.max(8, r.top - popH - 8) // flip above when there is no room below
: below;
setPos({ top, left, width });
return true;
}, [align]);
useLayoutEffect(() => {
if (!open) return;
updatePos();
// Second pass after paint: catches late layout shifts (fonts, images).
const raf = requestAnimationFrame(() => updatePos());
return () => cancelAnimationFrame(raf);
}, [open, updatePos]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onScroll = (e: Event) => {
if (popRef.current?.contains(e.target as Node)) return; // scrolling inside the list
updatePos(); // follow the anchor instead of closing
};
const onResize = () => updatePos();
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, updatePos]);
return (
<div className={className}>
<button
ref={btnRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"flex max-w-56 items-center gap-2 rounded-full border border-stone-200 bg-white py-2 pl-3.5 pr-3 text-sm font-medium shadow-sm transition",
"hover:border-accent-500/60 hover:shadow disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-zinc-700 dark:bg-zinc-900",
open && "border-accent-500 ring-2 ring-accent-500/20",
)}
>
{Icon && <Icon size={15} className="shrink-0 text-accent-600 dark:text-accent-400" />}
<span className="truncate">{selected?.label ?? shownPlaceholder}</span>
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
</button>
{open && createPortal(
<>
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
<div
ref={popRef}
role="listbox"
aria-label={ariaLabel}
className="fixed z-50 max-h-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{all.map((g) => (
<div key={g.group || "default"}>
{g.group && (
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider opacity-50">{g.group}</p>
)}
{g.options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
role="option"
aria-selected={active}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition",
active
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{o.label}</span>
{o.hint && <span className="block truncate text-xs opacity-50">{o.hint}</span>}
</span>
{active && <Check size={15} className="shrink-0 text-accent-600 dark:text-accent-400" />}
</button>
);
})}
</div>
))}
</div>
</>,
document.body,
)}
</div>
);
}
/* ---------- Language picker (framework-styled dropdown over LANGS) ---------- */
export function LangPicker({ align = "right" }: { align?: "left" | "right" }) {
const { t, lang } = useT();
return (
<Picker
ariaLabel={t("settings.language")}
value={lang}
onChange={(v) => setLang(v as LangId)}
align={align}
options={LANGS.map((l) => ({ value: l.id, label: l.label }))}
/>
);
}
/* ---------- Modal / Dialog ---------- */
export function Modal({
open,
onClose,
title,
icon,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
icon?: LucideIcon;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
// Bottom-sheet drag state (mobile): pull the handle down to dismiss.
const [dragY, setDragY] = useState(0);
const dragStart = useRef<number | null>(null);
useEffect(() => {
if (!open) {
setDragY(0);
dragStart.current = null;
}
}, [open ]);
const { t } = useT();
if (!open) return null;
const Icon = icon;
// Portal to <body>: ancestors with backdrop-blur / transforms (e.g. the
// login card) become containing blocks for fixed elements and would trap
// the dialog inside them. Portaling keeps every modal viewport-centered.
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
className={cn(
"relative max-h-[92vh] w-full overflow-y-auto rounded-b-none rounded-t-[1.75rem] border border-stone-200 bg-white p-6 pt-3 shadow-2xl sm:max-h-[85vh] sm:rounded-3xl sm:pt-6 dark:border-zinc-700 dark:bg-zinc-900",
wide ? "sm:max-w-2xl" : "sm:max-w-md",
)}
style={dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined}
>
{/* drag handle — mobile only */}
<div
className="sticky top-0 z-10 -mx-6 px-6 pb-3 pt-1 sm:hidden"
onTouchStart={(e) => { dragStart.current = e.touches[0]!.clientY; }}
onTouchMove={(e) => {
if (dragStart.current === null) return;
const dy = e.touches[0]!.clientY - dragStart.current;
setDragY(dy > 0 ? dy : 0);
}}
onTouchEnd={() => {
if (dragY > 110) onClose(); // snap shut past threshold…
setDragY(0); // …otherwise snap back
dragStart.current = null;
}}
>
<div className="mx-auto h-1.5 w-12 rounded-full bg-stone-300 dark:bg-zinc-600" />
</div>
<div className="mb-4 flex items-center gap-2.5">
{Icon && (
<span className="grid h-9 w-9 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Icon size={18} />
</span>
)}
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} aria-label={t("common.close")} className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<X size={18} />
</button>
</div>
{children}
</div>
</div>,
document.body,
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
}) {
const { t } = useT();
return (
<Modal open={open} onClose={onClose} title={title}>
<p className="text-sm opacity-80">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel ?? t("common.delete")}</Button>
</div>
</Modal>
);
}
/* ---------- Context menu ---------- */
export interface MenuItem {
icon: LucideIcon;
label: string;
danger?: boolean;
onClick: () => void;
}
export function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ x, y });
useEffect(() => {
const el = ref.current;
if (el) {
const r = el.getBoundingClientRect();
setPos({
x: Math.min(x, window.innerWidth - r.width - 8),
y: Math.min(y, window.innerHeight - r.height - 8),
});
}
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [x, y, onClose]);
// Rendered in a portal: the sidebar's backdrop-blur would otherwise turn
// into a containing block, breaking the fullscreen overlay and offsetting
// the menu (text showing through / misplaced over the composer).
return createPortal(
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }}>
<div
ref={ref}
className="fixed z-50 w-52 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1.5 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ left: pos.x, top: pos.y }}
onClick={(e) => e.stopPropagation()}
>
{items.map((it) => (
<button
key={it.label}
onClick={() => { it.onClick(); onClose(); }}
className={cn(
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-sm transition",
it.danger
? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<it.icon size={16} />
{it.label}
</button>
))}
</div>
</div>,
document.body,
);
}
/* ---------- Avatar ---------- */
// Uploaded avatars reuse the same /cdn/avatar/<userId> path, so the <img> URL
// never changes and browsers keep serving the stale cached file. A global
// version counter (bumped after each successful upload) cache-busts every
// /cdn/ URL rendered by <Avatar>, refreshing previews app-wide instantly.
let cdnVersion = 0;
const cdnVersionListeners = new Set<() => void>();
/** Call after a successful CDN upload so every <Avatar> re-fetches the file. */
export function bumpCdnVersion(): void {
cdnVersion++;
cdnVersionListeners.forEach((l) => l());
}
function useCdnVersion(): number {
const [v, setV] = useState(cdnVersion);
useEffect(() => {
const l = () => setV(cdnVersion);
cdnVersionListeners.add(l);
return () => { cdnVersionListeners.delete(l); };
}, []);
return v;
}
function withCdnVersion(url: string, v: number): string {
if (!url.startsWith("/cdn/")) return url;
return `${url}${url.includes("?") ? "&" : "?"}v=${v}`;
}
const GRADIENTS = [
"from-accent-500 to-teal-600",
"from-violet-500 to-purple-600",
"from-amber-500 to-orange-600",
"from-sky-500 to-cyan-600",
"from-rose-500 to-pink-600",
];
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
const cdnV = useCdnVersion();
if (url) {
return <img src={withCdnVersion(url, cdnV)} alt={name} width={size} height={size} className="rounded-full object-cover" style={{ width: size, height: size }} />;
}
const initial = (name.trim()[0] || "?").toUpperCase();
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
return (
<span
className={cn("grid shrink-0 place-items-center rounded-full bg-gradient-to-br font-semibold text-white", g)}
style={{ width: size, height: size, fontSize: size * 0.42 }}
aria-label={name}
>
{initial}
</span>
);
}
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
/**
* The KisAssistant flower mark. Petals follow the runtime accent color,
* heart stays amber — same artwork as the loading screen and favicon.
* `dynamic` controls the bloom animation: pass false for a static,
* full-bloom mark (e.g. thread bars and headers).
*/
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
const petals = [0, 60, 120, 180, 240, 300];
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className={cn("shrink-0", className)}
role="img"
aria-label="KisAssistant"
>
{petals.map((r, i) => (
<g key={r} transform={`rotate(${r} 50 50)`}>
<ellipse
cx="50"
cy="25"
rx="13"
ry="21"
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
/>
</g>
))}
<circle cx="50" cy="50" r="10" className={dynamic ? "flower-core fill-amber-400" : "fill-amber-400"} />
</svg>
);
}
/** Assistant identity: static flower mark on a soft circle (message avatars). */
export function AssistantAvatar({ size = 32 }: { size?: number }) {
return (
<span
className="grid shrink-0 place-items-center rounded-full bg-accent-600/10"
style={{ width: size, height: size }}
aria-label="KisAssistant"
>
<FlowerMark size={Math.round(size * 0.78)} dynamic={false} />
</span>
);
}
/* ---------- Switch (feature toggle) ---------- */
export function Switch({ checked, onChange, label, disabled = false }: { checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return <Toggle checked={checked} onChange={onChange} label={label} disabled={disabled} />;
}
/* ---------- Misc ---------- */
export function Spinner({ size = 18 }: { size?: number }) {
return <Loader2 size={size} className="animate-spin" />;
}
/** Accessible on/off switch (accent colored when on). Flex-based so the knob
* is always perfectly centered — no border/padding math to break. */
export function Toggle({
checked,
onChange,
label,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"flex h-6 w-11 shrink-0 items-center rounded-full px-0.5 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 disabled:cursor-not-allowed disabled:opacity-40",
checked ? "bg-accent-600 dark:bg-accent-500" : "bg-stone-300 dark:bg-zinc-700",
)}
>
<span
className={cn(
"h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 dark:bg-zinc-100",
checked ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
);
}
export function CopyButton({ text, label }: { text: string; label?: string }) {
const { t } = useT();
const [done, setDone] = useState(false);
return (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1500);
});
}}
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
{done ? <Check size={13} className="text-accent-500" /> : <Copy size={13} />}
{done ? t("common.copied") : (label ?? t("common.copy"))}
</button>
);
}
/* ---------- Loading screen ---------- */
/** Animated blooming flower shown while the app boots (themed, mobile-safe). */
export function LoadingScreen() {
const { t } = useT();
return (
<div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
<div className="flex flex-col items-center gap-4 px-6 text-center">
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Accent palette defaults (emerald). Overridden at runtime by lib/accent.ts
from the user's saved accent color / custom pick. Values are RGB triplets. */
:root {
--ka-accent-50: 236 253 245;
--ka-accent-100: 209 250 229;
--ka-accent-200: 167 243 208;
--ka-accent-300: 110 231 183;
--ka-accent-400: 52 211 153;
--ka-accent-500: 16 185 129;
--ka-accent-600: 5 150 105;
--ka-accent-700: 4 120 87;
--ka-accent-800: 6 95 70;
--ka-accent-900: 6 78 59;
}
html, body, #root {
height: 100%;
}
/* Mobile browser chrome (URL bar) shrinks the visible area — dynamic
viewport units keep the app glued to the real screen height. */
@supports (height: 100dvh) {
html, body, #root {
height: 100dvh;
}
}
body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
/* Kill the 300ms tap delay, keep gestures app-like. */
touch-action: manipulation;
overscroll-behavior-y: none;
}
/* slim scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: rgb(0 0 0 / 0.2) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.2);
border-radius: 999px;
}
/* markdown content */
.md p:first-child { margin-top: 0; }
.md p:last-child { margin-bottom: 0; }
.md pre {
@apply relative overflow-x-auto rounded-2xl border border-stone-200 bg-stone-900 p-4 text-[13.5px] leading-relaxed text-stone-100 dark:border-zinc-700;
}
.md pre code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: none !important;
padding: 0 !important;
}
.md :not(pre) > code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@apply rounded-md bg-stone-200/70 px-1.5 py-0.5 text-[13px] dark:bg-zinc-800;
}
.md table {
@apply w-full border-collapse overflow-hidden rounded-xl text-sm;
}
.md th, .md td {
@apply border border-stone-200 px-3 py-1.5 dark:border-zinc-700;
}
.md th {
@apply bg-stone-200/50 dark:bg-zinc-800;
}
.md a {
@apply text-accent-600 underline dark:text-accent-400;
}
.md ul { @apply list-disc pl-5; }
.md ol { @apply list-decimal pl-5; }
.md blockquote {
@apply border-l-4 border-accent-500/50 pl-3 italic opacity-80;
}
.typing-cursor::after {
content: "▍";
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Loading flower: petals bloom one after another, core breathes. */
.flower-petal {
transform-box: fill-box;
transform-origin: center;
animation: bloom 1.8s ease-in-out infinite;
}
@keyframes bloom {
0%, 100% { transform: scale(0.55); opacity: 0.3; }
50% { transform: scale(1); opacity: 1; }
}
.flower-core {
transform-box: fill-box;
transform-origin: center;
animation: core-breathe 1.8s ease-in-out infinite;
}
@keyframes core-breathe {
0%, 100% { transform: scale(0.85); }
50% { transform: scale(1.15); }
}
/* Smooth entrance for full-screen loaders. */
@keyframes loading-in {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.loading-enter {
animation: loading-in 0.45s ease-out both;
}
.loading-dots::after {
content: "";
animation: dots 1.4s steps(4) infinite;
}
@keyframes dots {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
@media (prefers-reduced-motion: reduce) {
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
animation: none;
}
}
/* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px rgb(245 245 244) inset; /* stone-100, matches soft fields */
-webkit-text-fill-color: #1c1917; /* stone-900 */
caret-color: #1c1917;
}
.dark input:-webkit-autofill,
.dark input:-webkit-autofill:hover,
.dark input:-webkit-autofill:focus,
.dark input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px #27272a inset; /* zinc-800 */
-webkit-text-fill-color: #f4f4f5; /* zinc-100 */
caret-color: #f4f4f5;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Runtime accent color (settings → accent color). Predefined palettes plus a
* custom picker; shades 50–900 are derived from a single base hex via HSL and
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
* palette. Persisted per-browser (localStorage), like the feature flags.
*/
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900", string>;
export interface AccentPreset {
id: string;
label: string;
base: string; // hex, used as the 500 shade and to derive the ramp
}
export const ACCENT_PRESETS: AccentPreset[] = [
{ id: "emerald", label: "Emerald", base: "#10b981" },
{ id: "rose", label: "Rose", base: "#f43f5e" },
{ id: "blue", label: "Blue", base: "#3b82f6" },
{ id: "violet", label: "Violet", base: "#8b5cf6" },
{ id: "amber", label: "Yellow", base: "#f59e0b" },
{ id: "orange", label: "Orange", base: "#f97316" },
{ id: "teal", label: "Teal", base: "#14b8a6" },
{ id: "pink", label: "Pink", base: "#ec4899" },
];
const STORAGE_KEY = "ka:accent";
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
// Lightness ramps for 50→900 around a mid 500 (HSL L values in %).
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24,
};
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return { h: 160, s: 84, l: 52 }; // emerald fallback
const n = parseInt(m[1]!, 16);
const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
}
return { h, s: s * 100, l: l * 100 };
}
function hslToRgbTriplet(h: number, s: number, l: number): string {
const sn = s / 100, ln = l / 100;
const c = (1 - Math.abs(2 * ln - 1)) * sn;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = ln - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const to = (v: number) => Math.round((v + m) * 255);
return `${to(r)} ${to(g)} ${to(b)}`;
}
/** Build the 50–900 ramp from a base hex (HSL ramp, emerald-style). */
export function shadesFor(baseHex: string): AccentShades {
const { h, s } = hexToHsl(baseHex);
const out = {} as AccentShades;
for (const k of SHADE_KEYS) {
// Keep saturation lively at the light end, richer at the dark end.
const sat = k === "50" || k === "100" ? s * 0.9 : s;
out[k] = hslToRgbTriplet(h, sat, RAMP[k]);
}
return out;
}
export interface AccentState {
/** preset id, or "custom" */
presetId: string;
/** hex for custom (ignored for presets — they derive from their base) */
customHex: string;
}
export function currentAccent(): AccentState {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as AccentState | null;
if (raw && typeof raw.presetId === "string") return raw;
} catch { /* ignore */ }
return { presetId: "emerald", customHex: "#10b981" };
}
export function applyAccent(state: AccentState): void {
const preset = ACCENT_PRESETS.find((p) => p.id === state.presetId);
const base = preset ? preset.base : (/^#?[0-9a-f]{6}$/i.test(state.customHex) ? state.customHex : "#10b981");
const shades = shadesFor(base);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
/** Preview without persisting (used while the picker is open). */
export function previewAccent(baseHex: string): void {
const shades = shadesFor(baseHex);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
}
// Restore the saved accent on load.
if (typeof document !== "undefined") {
applyAccent(currentAccent());
}
+142
View File
@@ -0,0 +1,142 @@
import type { AccessMessage, AccessRequest } from "./api.ts";
/**
* Shared WebSocket pool for live access-request updates.
*
* One underlying socket per URL (refcounted across components, e.g. the
* AdminCenter badge and the triage list share the admin firehose).
* Auto-reconnects with backoff; every (re)connect triggers onSync so
* components resync over HTTP and never miss an event sent while offline.
* A 25s client ping keeps idle connections alive through proxies.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest }
| { type: "pong" };
export interface AccessLiveListener {
onEvent: (e: AccessLiveEvent) => void;
/** Fired on every (re)connect — resync state over HTTP here. */
onSync?: () => void;
}
interface Pool {
ws: WebSocket | null;
listeners: Set<AccessLiveListener>;
backoff: number;
retryTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
}
const pools = new Map<string, Pool>();
export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
function connect(url: string, pool: Pool): void {
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch {
scheduleRetry(url, pool);
return;
}
pool.ws = ws;
ws.onopen = () => {
pool.backoff = 0;
pool.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
// send failed — the close handler will schedule a retry
}
}
}, 25_000);
for (const l of [...pool.listeners]) {
try {
l.onSync?.();
} catch {
// listener bug must not break the pool
}
}
};
ws.onmessage = (ev) => {
let parsed: AccessLiveEvent | null = null;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as AccessLiveEvent;
} catch {
return;
}
if (!parsed || typeof parsed.type !== "string") return;
for (const l of [...pool.listeners]) {
try {
l.onEvent(parsed);
} catch {
// listener bug must not break the pool
}
}
};
const onDead = () => {
if (pool.ws === ws) pool.ws = null;
if (pool.pingTimer) {
clearInterval(pool.pingTimer);
pool.pingTimer = null;
}
scheduleRetry(url, pool);
};
ws.onclose = onDead;
ws.onerror = () => {
// onerror is followed by onclose — retry is scheduled there
};
}
function scheduleRetry(url: string, pool: Pool): void {
if (!pools.has(url) || pool.listeners.size === 0 || pool.retryTimer) return;
const delay = Math.min(1000 * 2 ** pool.backoff, 15_000);
pool.backoff += 1;
pool.retryTimer = setTimeout(() => {
pool.retryTimer = null;
if (!pools.has(url) || pool.listeners.size === 0) return;
connect(url, pool);
}, delay);
}
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
let pool = pools.get(url);
if (!pool) {
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
pools.set(url, pool);
connect(url, pool);
}
pool.listeners.add(listener);
if (pool.ws && pool.ws.readyState === WebSocket.OPEN) {
try {
listener.onSync?.();
} catch {
// ignore
}
}
return () => {
const p = pools.get(url);
if (!p) return;
p.listeners.delete(listener);
if (p.listeners.size > 0) return;
pools.delete(url);
if (p.retryTimer) clearTimeout(p.retryTimer);
if (p.pingTimer) clearInterval(p.pingTimer);
try {
p.ws?.close();
} catch {
// already gone — fine
}
p.ws = null;
};
}
+392
View File
@@ -0,0 +1,392 @@
import type { ChatMessage, Conversation, DriverModel, SharedChat, User } from "./types.ts";
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
export interface AccessRequest {
id: string;
username: string;
email: string;
message: string;
status: AccessStatus;
reason: string;
created_at: number;
updated_at: number;
}
export interface AccessMessage {
id: number;
request_id: string;
author: "user" | "admin";
body: string;
created_at: number;
}
export type ReportReason = "copyright" | "gore" | "falseinfo" | "bug";
export type ReportStatus = "open" | "reviewing" | "resolved" | "dismissed";
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
content: string;
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
export interface ModelPolicyEntry {
enabled: boolean;
hourly: number;
daily: number;
}
export interface UserProvider {
provider: string;
label: string;
hasKey: boolean;
last4: string | null;
updatedAt: number;
}
export interface PolicyModel extends DriverModel {
enabled: boolean;
hourly: number;
daily: number;
}
const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(t: string): void {
localStorage.setItem(TOKEN_KEY, t);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, opts: RequestInit = {}, auth = true): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth) {
const t = getToken();
if (t) headers["Authorization"] = `Bearer ${t}`;
}
const res = await fetch(path, { ...opts, headers: { ...headers, ...(opts.headers as Record<string, string> | undefined) } });
if (res.status === 401 && auth) {
clearToken();
if (!window.location.pathname.startsWith("/share/")) window.location.reload();
throw new ApiError(401, "Session expired, please log in again.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error || `Request failed (${res.status})`);
return data as T;
}
export const api = {
login: (username: string, key: string) =>
req<{ token: string; expiresAt: number; user: User } | { totpRequired: true; totpToken: string; username: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, key }),
}, false),
totpLogin: (totpToken: string, code: string) =>
req<{ token: string; expiresAt: number; user: User }>("/api/auth/totp", {
method: "POST",
body: JSON.stringify({ totpToken, code }),
}, false),
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
methods: () => req<{ recovery: boolean; from?: string; registration: boolean; accessRequest: boolean }>("/api/auth/methods", {}, false),
register: (u: { username: string; displayName?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/auth/register", { method: "POST", body: JSON.stringify(u) }, false),
recover: (username: string) =>
req<{ ok: boolean }>("/api/auth/recover", { method: "POST", body: JSON.stringify({ username }) }, false),
resetCheck: (token: string) => req<{ ok: boolean; username: string }>(`/api/auth/reset/${token}`, {}, false),
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
/** Rotate your own access key (old sessions revoked, key shown once). */
rotateKey: () => req<{ key: string }>("/api/me/key/rotate", { method: "POST" }),
/** Permanently delete your own account (chats, shares, sessions). */
deleteMe: () => req<{ ok: boolean }>("/api/me", { method: "DELETE" }),
/** TOTP two-factor self-service. */
totpStatus: () => req<{ enabled: boolean }>("/api/me/totp"),
totpSetup: () => req<{ secret: string; otpauthUrl: string }>("/api/me/totp/setup", { method: "POST" }),
totpVerify: (secret: string, code: string) =>
req<{ enabled: boolean }>("/api/me/totp/verify", { method: "POST", body: JSON.stringify({ secret, code }) }),
totpDisable: (code: string) =>
req<{ enabled: boolean }>("/api/me/totp", { method: "DELETE", body: JSON.stringify({ code }) }),
adminList: (p: { q?: string; sort?: string; filter?: string; page?: number; per?: number } = {}) => {
const qs = new URLSearchParams();
if (p.q) qs.set("q", p.q);
if (p.sort) qs.set("sort", p.sort);
if (p.filter) qs.set("filter", p.filter);
if (p.page) qs.set("page", String(p.page));
if (p.per) qs.set("per", String(p.per));
const s = qs.toString();
return req<{ users: User[]; total: number; page: number; perPage: number; pages: number }>(
`/api/admin/users${s ? `?${s}` : ""}`,
);
},
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminShadowban: (id: string, shadowbanned: boolean) =>
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
method: "POST",
body: JSON.stringify({ shadowbanned }),
}),
/** Admin: per-model access policy (kill-switch + rate limits). */
adminModelPolicy: (refresh = false) =>
req<{ models: PolicyModel[] }>(`/api/admin/model-policy${refresh ? "?refresh=1" : ""}`),
adminModelPolicyPatch: (model: string, patch: { enabled?: boolean; hourly?: number; daily?: number }) =>
req<{ model: PolicyModel }>(`/api/admin/model-policy`, {
method: "PATCH",
body: JSON.stringify({ model, ...patch }),
}),
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings"),
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) =>
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
adminMailStatus: () =>
req<{ smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; pass: string; hasPass: boolean; from: string; appUrl: string } }>("/api/admin/mail"),
adminMailVerify: () => req<{ ok: boolean }>("/api/admin/mail/verify", { method: "POST", body: JSON.stringify({}) }),
adminMailTest: (to: string) =>
req<{ ok: boolean; messageId: string }>("/api/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
/** Search your old chats (titles, topics and message content). */
searchConvs: (q: string) =>
req<{ conversations: (Conversation & { snippet: string | null; snippetRole: string | null })[] }>(
`/api/conversations/search?q=${encodeURIComponent(q)}`,
),
createConv: (c: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }),
getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`),
patchConv: (id: string, patch: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>(`/api/conversations/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteConv: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}`, { method: "DELETE" }),
share: (id: string) => req<{ publicId: string; url: string }>(`/api/conversations/${id}/share`, { method: "POST" }),
getShare: (id: string) => req<{ publicId: string; url: string } | { shared: false }>(`/api/conversations/${id}/share`),
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
/** Archived chats (hidden from the sidebar, managed in settings). */
archivedConvs: () => req<{ conversations: Conversation[] }>("/api/conversations/archived"),
archiveConv: (id: string) =>
req<{ conversation: Conversation }>(`/api/conversations/${id}/archive`, { method: "POST" }),
unarchiveConv: (id: string) =>
req<{ conversation: Conversation }>(`/api/conversations/${id}/unarchive`, { method: "POST" }),
models: (opts: { refresh?: boolean } = {}) =>
req<{ models: DriverModel[] }>(`/api/models${opts.refresh ? "?refresh=1" : ""}`),
/** Access-request wishlist (public, no auth). */
accessRequest: (a: { username: string; email: string; message: string }) =>
req<{ request: AccessRequest; reviewUrl: string }>("/api/access/request", {
method: "POST",
body: JSON.stringify(a),
}, false),
accessTicket: (id: string) =>
req<{ request: AccessRequest; messages: AccessMessage[] }>(`/api/access/ticket/${id}`, {}, false),
accessReply: (id: string, body: string) =>
req<{ message: AccessMessage }>(`/api/access/ticket/${id}/message`, {
method: "POST",
body: JSON.stringify({ body }),
}, false),
/** Admin: access-request triage. */
adminAccessList: () => req<{ requests: (AccessRequest & { message_count: number })[] }>("/api/admin/access"),
adminAccessGet: (id: string) =>
req<{ request: AccessRequest; messages: AccessMessage[] }>(`/api/admin/access/${id}`),
adminAccessPatch: (id: string, patch: { status: AccessStatus; reason?: string }) =>
req<{ request: AccessRequest; key?: string }>(`/api/admin/access/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
adminAccessReply: (id: string, body: string) =>
req<{ message: AccessMessage }>(`/api/admin/access/${id}/message`, {
method: "POST",
body: JSON.stringify({ body }),
}),
/** Content reports (flagged AI responses). */
createReport: (r: {
conversationId?: string;
messageIndex?: number;
reason: ReportReason;
details?: string;
content?: string;
prompt?: string;
model?: string;
}) =>
req<{ report: Report }>("/api/reports", {
method: "POST",
body: JSON.stringify(r),
}),
/** Admin: report triage. */
adminReportList: () => req<{ reports: Report[] }>("/api/admin/reports"),
adminReportPatch: (id: string, patch: { status: ReportStatus; adminNote?: string }) =>
req<{ report: Report }>(`/api/admin/reports/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
ollamaPull: async (
name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch("/api/admin/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ name }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Pull failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (t) onProgress(JSON.parse(t));
}
}
},
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }),
/** Personal LLM providers (BYOK): presence + last4 only, keys never leave the server. */
providers: () => req<{ providers: UserProvider[] }>("/api/me/providers"),
setProvider: (provider: string, apiKey: string) =>
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, {
method: "PUT",
body: JSON.stringify({ apiKey }),
}),
deleteProvider: (provider: string) =>
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
const res = await fetch("/api/tools/ocr", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string; text?: string; truncated?: boolean; chars?: number };
if (!res.ok) throw new ApiError(res.status, data.error || `OCR failed (${res.status})`);
return { text: data.text ?? "", truncated: !!data.truncated, chars: data.chars ?? 0 };
},
/** Avatar upload to the local CDN. Returns the public /cdn/… URL. */
uploadAvatar: async (userId: string, file: File): Promise<{ url: string }> => {
const res = await fetch(`/cdn/avatar/${userId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string; url?: string };
if (!res.ok) throw new ApiError(res.status, data.error || `Upload failed (${res.status})`);
return { url: data.url! };
},
/** Streaming chat. Calls onToken per token; resolves with the full text. */
chatStream: async (
args: { model: string; messages: ChatMessage[]; conversationId?: string },
onToken: (t: string) => void,
): Promise<string> => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ ...args, stream: true }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Chat failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let full = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop() ?? "";
for (const ev of events) {
const m = ev.match(/^event: (\w+)\ndata: ([\s\S]*)$/m);
if (!m) continue;
const [, event, data] = m as [string, string, string];
if (event === "token") {
const token = (JSON.parse(data) as { token: string }).token;
full += token;
onToken(token);
} else if (event === "error") {
throw new ApiError(502, (JSON.parse(data) as { error?: string }).error || "stream error");
}
}
}
return full;
},
};
+3
View File
@@ -0,0 +1,3 @@
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
+65
View File
@@ -0,0 +1,65 @@
import { useSyncExternalStore } from "react";
/**
* Per-browser feature flags (localStorage). Gate optional composer
* capabilities; kept client-side on purpose — they only change what the
* UI offers / what prompt decorations are sent, never auth or data access.
*/
export type FeatureKey = "thinking" | "attachments" | "deepSearch";
export const FEATURES: { key: FeatureKey; ready: boolean }[] = [
{ key: "thinking", ready: true },
{ key: "attachments", ready: true },
{ key: "deepSearch", ready: false },
];
const STORAGE_KEY = "ka:features";
const DEFAULTS: Record<FeatureKey, boolean> = {
thinking: false,
attachments: true,
deepSearch: false,
};
function load(): Record<FeatureKey, boolean> {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as Partial<Record<FeatureKey, boolean>>;
return { ...DEFAULTS, ...raw };
} catch {
return { ...DEFAULTS };
}
}
let flags = load();
const listeners = new Set<() => void>();
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(flags));
} catch {
// private mode etc. — flags just won't survive a reload
}
listeners.forEach((l) => l());
}
export function featureEnabled(key: FeatureKey): boolean {
return flags[key];
}
export function setFeature(key: FeatureKey, value: boolean) {
flags = { ...flags, [key]: value };
persist();
}
export function useFeatures(): Record<FeatureKey, boolean> {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
},
() => flags,
);
}
/** System message prepended to /api/chat when the "thinking" flag is on. */
export const THINKING_SYSTEM_PROMPT =
"Think step-by-step before answering. Start with a short 'Thoughts' section showing your reasoning, then give the final answer under an 'Answer' heading.";
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
/** Tiny pathname router (no dependency): /login, /register, /chat, /. */
export function normalizePath(p: string): string {
if (p.length > 1 && p.endsWith("/")) return p.slice(0, -1);
return p || "/";
}
export function getPath(): string {
return normalizePath(window.location.pathname);
}
/** Client-side navigation without reload (same-origin path only). */
export function navigate(to: string, replace = false): void {
const dest = normalizePath(to);
if (getPath() === dest) return;
if (replace) window.history.replaceState(null, "", dest);
else window.history.pushState(null, "", dest);
window.dispatchEvent(new PopStateEvent("popstate"));
}
export function useRoute(): string {
const [route, setRoute] = useState(getPath);
useEffect(() => {
const h = () => setRoute(getPath());
window.addEventListener("popstate", h);
return () => window.removeEventListener("popstate", h);
}, []);
return route;
}
+59
View File
@@ -0,0 +1,59 @@
export interface User {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: string;
email: string;
createdAt: number;
isAdmin: boolean;
}
export interface Conversation {
id: string;
user_id: string;
title: string;
topic: string;
model: string;
archived_at: number;
created_at: number;
updated_at: number;
}
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface DriverModel {
id: string;
name: string;
driver: string;
label: string;
group?: string;
}
export interface Attachment {
id: string;
name: string;
kind: "ocr" | "text";
text: string;
}
export interface FilePreview {
name: string;
kind: "ocr" | "text";
text: string;
truncated: boolean;
}
export interface SharedChat {
title: string;
topic: string;
model: string;
authorName: string;
authorAvatarUrl: string;
archived: boolean;
sharedAt: number;
messages: ChatMessage[];
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";
import { App } from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+29
View File
@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
fontFamily: {
sans: ["Inter Variable", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
},
/* Accent palette backed by CSS variables so the user can recolor the
UI at runtime (settings → accent color). Defaults = emerald. */
colors: {
accent: {
50: "rgb(var(--ka-accent-50) / <alpha-value>)",
100: "rgb(var(--ka-accent-100) / <alpha-value>)",
200: "rgb(var(--ka-accent-200) / <alpha-value>)",
300: "rgb(var(--ka-accent-300) / <alpha-value>)",
400: "rgb(var(--ka-accent-400) / <alpha-value>)",
500: "rgb(var(--ka-accent-500) / <alpha-value>)",
600: "rgb(var(--ka-accent-600) / <alpha-value>)",
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
},
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
// Build directly into the backend's static dir (single-origin deploy).
build: {
outDir: "../public",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
// Object form needed for ws:true — access-request live updates ride
// a WebSocket under /api (proxied in dev, same-origin in prod).
"/api": { target: "http://localhost:3000", ws: true },
},
},
});
+22
View File
@@ -0,0 +1,22 @@
Changement frontend à faire:
faire du joli front-end css rounded comme un peu signal:
police système override par une font belle
les systèmes de Chat à gauche: Collapsable , joli contextual menu pour delete chat / share it pubicly
(tu vas faire une page /share que les chat système partager pourront être vu publiquement)
Des îcones joli avec lucide.dev
Tu peux revoir le frotnend pour faire un framework joli.
Les bouttons native HTML5/CSS/JS:
Vraiment moche, les boutons bleu sont pas ebau, utilise un framework HTML telle que
- Svelte avec SHADCDN
- ou bien React Lucide avec tailwindCSS +
Tu vas revoir le style globale,
SYSTÈME DE COMPTE CROSS-OVER AVEC APSSWORD DANS LE .ENV
FAIRE UN SYSTÈME DE SUBSTAINBILITY.
AVEC UNE BASE DE DONNÉES .
-> Utilise le driver sqlite de bun native.
à garder en db permantente:
- Les chats avec leur ids, leur thémathiqeu
- Les username, displayName, les comptes
- les thèmes des comptes, leur avatar héberger sur un cdn distant (catbox.moe par exemple)
Faire un système de compte No-KYC avec des clées gérer par le compte admin qui as sa clé dans le .env
Modal à intégrer dans le lucide dev, les selcteur de choix,dialog box, bouton, confirm etc doit être handle par un beau frontend qui est hooké par le framework
+135
View File
@@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.kisassistant;
# Backward-compatible tri-state: null (default) keeps the legacy behaviour
# (setting `domain` activates nginx), true forces it on, false forces it off.
nginxEnabled =
if cfg.enableNginx != null then cfg.enableNginx
else cfg.domain != null;
in {
options.services.kisassistant = {
enable = lib.mkEnableOption "KisAssistant private chat assistant";
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = ''
KisAssistant package built from this flake, e.g.
`kisassistant.packages.''${pkgs.system}.default`.
Required when the service is enabled.
'';
};
port = lib.mkOption { type = lib.types.port; default = 3000; };
host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; };
dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/kisassistant"; };
user = lib.mkOption { type = lib.types.str; default = "kisassistant"; };
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store).";
};
ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; };
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "nginx virtualHost name (e.g. chat.kisakay.com). Required when enableNginx is true.";
};
enableNginx = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Reverse-proxy the service through nginx with TLS + ACME.
- `true`: nginx is configured (requires `domain`).
- `false`: nginx is never configured, even if `domain` is set.
- `null` (default): legacy behaviour — nginx is configured iff `domain` is set.
'';
};
enableBrowserDriver = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable the Arcaic backend (headless Firefox from the nix store
running a web-UI session inside the service).
Sets ARCAIC_ENABLED/ARCAIC_HEADLESS/ARCAIC_EXECUTABLE
(still overridable via extraEnv).
'';
};
extraEnv = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Extra env (MISTRAL_API_KEY, DEEPSEEK_ENABLED, …). Prefer files for secrets.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.package != null;
message = "services.kisassistant.package must be set, e.g. kisassistant.packages.\${pkgs.system}.default";
}
{
assertion = !(nginxEnabled && cfg.domain == null);
message = "services.kisassistant.enableNginx requires services.kisassistant.domain to be set";
}
];
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.user;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.user} = {};
systemd.services.kisassistant = {
description = "KisAssistant";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
PORT = toString cfg.port;
HOST = cfg.host;
NODE_ENV = "production";
DATA_DIR = "${cfg.dataDir}/data";
CDN_DIR = "${cfg.dataDir}/cdn";
DRIVER_DEBUG = "false";
OLLAMA_HOST = cfg.ollamaHost;
} // lib.optionalAttrs cfg.enableBrowserDriver {
ARCAIC_ENABLED = "true";
ARCAIC_HEADLESS = "true";
ARCAIC_EXECUTABLE = "${pkgs.firefox}/bin/firefox";
} // cfg.extraEnv;
serviceConfig = {
User = cfg.user;
Group = cfg.user;
Restart = "always";
StateDirectory = "kisassistant";
WorkingDirectory = cfg.dataDir;
} // lib.optionalAttrs (cfg.passwordFile != null) {
LoadCredential = "app-password:${cfg.passwordFile}";
};
# tesseract binary for the OCR platform tool (see TESSERACT_BIN);
# firefox for the Arcaic backend (enableBrowserDriver).
path = [ pkgs.tesseract ] ++ lib.optionals cfg.enableBrowserDriver [ pkgs.firefox ];
script = ''
${lib.optionalString (cfg.passwordFile != null) ''
export APP_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/app-password")"
''}
exec ${cfg.package}/bin/kisassistant
'';
};
services.nginx = lib.mkIf nginxEnabled {
enable = true;
recommendedProxySettings = true;
virtualHosts.${cfg.domain} = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://${cfg.host}:${toString cfg.port}";
# Never buffer SSE (/api/chat streams tokens like ChatGPT).
extraConfig = "proxy_buffering off;";
};
};
};
};
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "kisassistant",
"version": "0.2.0",
"description": "KisAssistant — private ChatGPT-like assistant (Bun backend, React frontend, SQLite)",
"author": "kisakay",
"license": "MIT",
"repository": {
"type": "git",
"url": "http://git.kisakay.com/k/chat"
},
"homepage": "https://chat.kisakay.com",
"type": "module",
"engines": {
"bun": ">=1.1"
},
"scripts": {
"dev": "bun run --watch src/index.ts",
"dev:frontend": "bun --cwd frontend dev",
"dev:all": "bun --cwd frontend dev & bun run --watch src/index.ts & wait",
"start": "bun run src/index.ts",
"build:frontend": "bun --cwd frontend build",
"typecheck": "tsc --noEmit",
"typecheck:frontend": "bun --cwd frontend tsc --noEmit"
},
"dependencies": {
"nodemailer": "^10.0.10",
"puppeteer-core": "^25.11.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/nodemailer": "^8.0.2"
}
}
+35
View File
@@ -0,0 +1,35 @@
faire un mocking de ChatGPT juste avec mdp en hosted sur
chat.kisakay.com
Pas d 'api backend, soit utiliser Olama en LLM ou Pupeteer sur un chagpt.com par exemple.
Cahier des charges: faire un site ultra minimlasite en HTMl/CSS/JS pur sans de ressource lourde à charger.
ou bien sert toi d'un framework JS/TS mais choisis le en fonciton des recommendations
un site facile, Desktop Oriented first, avec mdp à la connection + gestion auth via clé.
évite cookies -> utilise une clé one time + rate limite sur auth
Donc faire un backend.
Choix du langage: golang ou rust ou bun.
Doit être facilement hébergable via un NixOS (soit module, ou flakes git)
Le git est un git custom gitea. git.kisakay.com <- remote HTTP
le site doit être simple, gestion thème blanc + noir(automatiquement) only english
le site doit imiter l'interface de chatgpt/ claude
le nom du truc seras :"KisAssistant" (kisa 's assistant)
le backend doit gérer les modèles de manière intéressante (logique orienté classe comme des drivers)
Exemple: driver openai
-> Gesiton du driver via un crawler (telle que pupeteer + scraping backend sur le site.)
Exemple: modèle openai -> backend utilise un browser headless pour use openai pour hooké le prompt
- Driver Ollama avec modèle disponible:
Utilisation par exemple de Ollama définis via le backend; montre les modèles disponible, Utilisation de ceux-si...
- Api OpenAI/Anthropic/Deepseek
Tu peux les implémenters, surtout dans ces priorités:
DeepSeek -> Anthropic -> OpenAI. Tu feras ces drivers à la fin.
autre exemple: Modèle gratuit: Mistral
une des priorités des drivers:
Mistral
priorités des drivers global:
llm Local via ollama -> Api mistral -> driver openai (pupeteer) ->Api (avec leur priorités distinct)
le site web doit gérer le markdown des input/output. (utilise des plugins react par exemple)
Si tu as une question, hésite ABSOLUMENT pas à demander
Symlink
+1
View File
@@ -0,0 +1 @@
/nix/store/l149xpcs2ywk0kl4l2lk0avddfviarnq-kisassistant-0.2.0
+96
View File
@@ -0,0 +1,96 @@
/**
* POC — drive an Arcaic web-UI backend inside a totally temporary Firefox
* profile (puppeteer-core, WebDriver BiDi, headless by default). The profile
* is created in /tmp at launch and deleted on exit; login is done manually
* in the window (with --headed) when the script asks for it.
*
* Usage:
* bun scripts/poc-chatgpt.ts # send default test message (openai site)
* bun scripts/poc-chatgpt.ts --site qwen # openai | qwen | gemini
* bun scripts/poc-chatgpt.ts --message "hello" # custom message (\n supported)
* bun scripts/poc-chatgpt.ts --message $'l1\nl2' # multi-line test
* bun scripts/poc-chatgpt.ts --probe # just open + report page state
* bun scripts/poc-chatgpt.ts --headed # visible window (debug / manual login)
* bun scripts/poc-chatgpt.ts --timeout-s 600 # longer response timeout
*/
import { ArcaicBrowserEngine, SITES } from "../src/drivers/browser.ts";
const DEFAULT_MESSAGE = "Hello! Answer with one short sentence so we know the pipeline works.";
function flag(name: string): boolean {
return process.argv.includes(name);
}
function option(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
}
const exe = process.env.ARCAIC_EXECUTABLE ?? "firefox";
const executablePath = exe.includes("/") ? exe : Bun.which(exe);
if (!executablePath) {
console.error("firefox not found — set ARCAIC_EXECUTABLE to the Firefox binary path");
process.exit(1);
}
const responseTimeoutS = Number(option("--timeout-s") ?? 300);
if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) {
console.error("--timeout-s must be a positive number of seconds");
process.exit(1);
}
const siteKey = option("--site") ?? "openai";
const site = SITES[siteKey];
if (!site) {
console.error(`--site must be one of: ${Object.keys(SITES).join(" | ")}`);
process.exit(1);
}
const engine = new ArcaicBrowserEngine({
executablePath,
headless: !flag("--headed"),
loginTimeoutMs: 600_000,
responseTimeoutMs: responseTimeoutS * 1000,
});
let interrupted = 0;
process.on("SIGINT", () => {
interrupted++;
if (interrupted > 1) process.exit(130);
console.log("\n[interrupt] closing browser and deleting the temporary profile…");
void engine.close().finally(() => process.exit(130));
});
const t0 = Date.now();
const step = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${msg}`);
try {
if (flag("--probe")) {
step(`launching Firefox (${executablePath}) with a fresh throwaway profile [site=${site.key}]`);
const info = await engine.probe(site);
console.log(JSON.stringify(info, null, 2));
step(info.composer ? `composer ready: ${info.composer}` : "composer NOT found — login wall or challenge page");
} else {
const message = option("--message") ?? DEFAULT_MESSAGE;
step(`launching Firefox (${executablePath}${flag("--headed") ? ", headed" : ", headless"}) — temporary profile, deleted on exit [site=${site.key}]`);
let streamed = "";
const final = await engine.send(message, {
site,
onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),
onToken: (delta) => {
streamed += delta;
process.stdout.write(delta);
},
});
console.log();
if (!streamed.trim()) console.log(final);
step(`done — response above (${final.length} chars)`);
}
process.exitCode = 0;
} catch (e) {
console.error(`\n[error] ${(e as Error).message}`);
process.exitCode = 1;
} finally {
await engine.close();
step("browser closed, temporary profile deleted");
}
+181
View File
@@ -0,0 +1,181 @@
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
/**
* Access-request wishlist (registration approval queue) + minimal ticket
* system. Tables are created idempotently here so db.ts stays untouched.
*
* Flow: visitor reserves username+email with a motivation message ->
* ticket page at /review/<request-id> (UUID, unguessable — same pattern as
* public share links) -> admin triages in the AdminCenter (reviewing /
* accepted / refused, each with a reason) -> both sides exchange messages;
* every admin event emails the requester (notification-only).
*/
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
export interface AccessRequest {
id: string;
username: string;
email: string;
message: string;
status: AccessStatus;
reason: string;
created_at: number;
updated_at: number;
}
export interface AccessMessage {
id: number;
request_id: string;
author: "user" | "admin";
body: string;
created_at: number;
}
export function initAccessTables(): void {
const d = getDb();
const createRequests = `CREATE TABLE access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`;
const createMessages = `CREATE TABLE access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`;
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
// Idempotent migration (must run BEFORE access_messages exists with an FK
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
// would otherwise poison access_messages with a dangling reference).
// Older builds created access_requests with a NOT NULL UNIQUE token_hash
// column (since removed — tickets are authed by their unguessable UUID).
// CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
try {
const cols = d.query("PRAGMA table_info(access_requests)").all() as { name: string }[];
if (cols.some((c) => c.name === "token_hash")) {
try {
d.query("PRAGMA foreign_keys=OFF").run();
try {
d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run();
} catch {
// SQLite builds without DROP COLUMN support: rebuild table.
d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run();
d.query(createRequests).run();
d.query(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`).run();
d.query("DROP TABLE access_requests_old").run();
}
} finally {
d.query("PRAGMA foreign_keys=ON").run();
}
}
} catch {
// Leave schema as-is; the insert error will surface normally.
}
// Repair: a previous boot migrated in the wrong order (messages table
// created before the DROP COLUMN), leaving access_messages with
// REFERENCES "access_requests_old". Rebuild it with the correct FK.
try {
const row = d.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'").get() as { sql: string } | null;
if (row?.sql?.includes("access_requests_old")) {
d.query("PRAGMA foreign_keys=OFF").run();
try {
d.query("DROP TABLE IF EXISTS access_messages_old").run();
d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run();
d.query(createMessages).run();
d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at)
SELECT id, request_id, author, body, created_at FROM access_messages_old`).run();
d.query("DROP TABLE access_messages_old").run();
} finally {
d.query("PRAGMA foreign_keys=ON").run();
}
}
} catch {
// Leave schema as-is; errors will surface on write.
}
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
}
export function createAccessRequest(opts: {
username: string;
email: string;
message: string;
}): AccessRequest {
const now = Date.now();
const row: AccessRequest = {
id: randomUUID(),
username: opts.username,
email: opts.email,
message: opts.message,
status: "pending",
reason: "",
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
return row;
}
export function getAccessRequest(id: string): AccessRequest | null {
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
}
/** Open (non-terminal) request already reserving this username or email. */
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
return getDb().query(
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
).get(username, email) as AccessRequest | null;
}
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
return getDb().query(
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
FROM access_requests r ORDER BY r.updated_at DESC`,
).all() as (AccessRequest & { message_count: number })[];
}
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
return getAccessRequest(id);
}
export function listAccessMessages(requestId: string): AccessMessage[] {
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
}
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
const now = Date.now();
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
return { id, request_id: requestId, author, body, created_at: now };
}
+48
View File
@@ -0,0 +1,48 @@
import type { AccessMessage, AccessRequest } from "./access.ts";
/**
* Live access-request updates over WebSocket (replaces HTTP polling).
*
* Two channels, mirroring the HTTP auth model (no weakening):
* - ticket socket `/api/access/ws/:id` — capability auth by unguessable
* UUID, exactly like GET /api/access/ticket/:id. Receives this ticket's
* messages + status changes.
* - admin socket `/api/admin/ws?token=…` — admin Bearer (query param
* because browsers can't set WS headers). Receives every access event,
* driving the AdminCenter badge + triage list.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest };
export interface AccessLiveSubscriber {
/** Ticket to follow, or null for the admin firehose. */
ticketId: string | null;
isAdmin: boolean;
/** Send a pre-serialized frame. Return false when the socket is dead. */
send: (data: string) => boolean;
}
const subscribers = new Set<AccessLiveSubscriber>();
export function subscribeAccessLive(s: AccessLiveSubscriber): () => void {
subscribers.add(s);
return () => {
subscribers.delete(s);
};
}
export function broadcastAccessLive(evt: AccessLiveEvent): void {
if (subscribers.size === 0) return;
const data = JSON.stringify(evt);
for (const s of [...subscribers]) {
if (s.ticketId !== evt.request_id && !(s.isAdmin && s.ticketId === null)) continue;
try {
if (!s.send(data)) subscribers.delete(s);
} catch {
subscribers.delete(s);
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { config } from "./config.ts";
import {
createSession,
deleteSession,
getDb,
getSession,
getUserById,
getUserByUsername,
toPublicUser,
type PublicUser,
} from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser };
function sha256hex(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/** Compare two secrets in constant time (via their hashes so lengths match). */
export function secretsEqual(a: string, bHash: string): boolean {
const aHash = sha256hex(a);
const ab = Buffer.from(aHash, "utf8");
const bb = Buffer.from(bHash, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
export function hashSecret(s: string): string {
return sha256hex(s);
}
/** Fresh random per-account access key (No-KYC: admin hands it to the user). Shown only once. */
export function newAccessKey(): string {
return "ka_" + randomBytes(24).toString("base64url");
}
function checkAdminKey(candidate: string): boolean {
const a = createHash("sha256").update(candidate).digest();
const b = createHash("sha256").update(config.appPassword).digest();
return timingSafeEqual(a, b);
}
/**
* Login with { username, key }.
* - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key.
*/
export function login(username: string, key: string): PublicUser | null {
if (username === "admin") {
if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin");
if (!admin) return null;
return toPublicUser(admin);
}
const u = getUserByUsername(username);
if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u);
}
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt };
}
export function verifyToken(token: string): PublicUser | null {
const s = getSession(sha256hex(token));
if (!s) return null;
const u = getUserById(s.user_id);
if (!u) {
deleteSession(sha256hex(token));
return null;
}
return toPublicUser(u);
}
export function revokeToken(token: string): void {
deleteSession(sha256hex(token));
}
export function extractBearer(req: Request): string | null {
const h = req.headers.get("authorization");
if (!h) return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1]!.trim() : null;
}
// --- sliding-window rate limiter (per IP) for /api/auth/login ---
const attempts = new Map<string, number[]>();
export function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowStart = now - config.rateLimitWindowMs;
const list = (attempts.get(ip) ?? []).filter((t) => t > windowStart);
attempts.set(ip, list);
return list.length >= config.rateLimitMax;
}
export function recordAttempt(ip: string): void {
const list = attempts.get(ip) ?? [];
list.push(Date.now());
attempts.set(ip, list);
}
export function clearAttempts(ip: string): void {
attempts.delete(ip);
}
+178
View File
@@ -0,0 +1,178 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { config } from "./config.ts";
// --- local file CDN -----------------------------------------------
// Generic, future-proof layout:
//
// PUT /cdn/<ns>/<key> (auth) -> { url: "/cdn/<ns>/<key>.<ext>" }
// GET /cdn/<ns>/<key>.<ext> (public)
//
// <ns> is a registered namespace (see CDN_NAMESPACES — `avatar` today,
// more tomorrow). <key> is namespaced client-chosen id ([A-Za-z0-9_-]).
// The stored extension ALWAYS comes from magic-byte detection, never from
// the client. Everything else is systematically blacklisted.
export type ImageExt = "jpg" | "png" | "webp";
export type TextExt = "txt";
export type CdnExt = ImageExt | TextExt;
export interface CdnNamespace {
maxBytes: number;
windowMs: number;
maxUploads: number;
/** Allowed content kinds for this namespace. */
kinds: ("image" | "text")[];
}
export const CDN_NAMESPACES: Record<string, CdnNamespace> = {
// Avatar rules (spec): 5 MB max, 5 changes max per 2 h per account.
avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5, kinds: ["image"] },
// Chat text attachments (spec): 500 KB max plain-text files.
text: { maxBytes: 500 * 1024, windowMs: 3600_000, maxUploads: 30, kinds: ["text"] },
};
export const CDN_EXTS: CdnExt[] = ["jpg", "png", "webp", "txt"];
const CDN_MIME: Record<CdnExt, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
txt: "text/plain; charset=utf-8",
};
export function cdnMime(ext: CdnExt): string {
return CDN_MIME[ext];
}
export function validNamespace(ns: string): boolean {
return /^[a-z0-9]{1,16}$/.test(ns) && ns in CDN_NAMESPACES;
}
export function validKey(key: string): boolean {
return /^[A-Za-z0-9_-]{1,64}$/.test(key);
}
export function validExt(ext: string): ext is CdnExt {
return (CDN_EXTS as string[]).includes(ext);
}
function cdnDir(): string {
return config.cdnDir;
}
export function cdnPath(ns: string, key: string, ext: CdnExt): string {
return join(cdnDir(), ns, `${key}.${ext}`);
}
export async function cdnFile(ns: string, key: string, ext: CdnExt): Promise<{ path: string; exists: boolean }> {
const path = cdnPath(ns, key, ext);
return { path, exists: await Bun.file(path).exists() };
}
/**
* Detect the real image type from magic bytes (first bytes of the file).
* Extension alone is NEVER trusted.
* JPEG: FF D8 FF
* PNG: 89 50 4E 47 0D 0A 1A 0A
* WEBP: "RIFF"...."WEBP" (bytes 0-3 and 8-11)
*/
export function detectImageType(buf: Uint8Array): ImageExt | null {
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
if (
buf.length >= 8 &&
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
) {
return "png";
}
if (
buf.length >= 12 &&
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && // RIFF
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 // WEBP
) {
return "webp";
}
return null;
}
/**
* Detect plain-text content: strict UTF-8 decode of a sample plus no NUL
* bytes anywhere (binary files are rejected, not just mislabeled ones).
*/
export function isTextContent(buf: Uint8Array): boolean {
if (buf.length === 0) return false;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0) return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, Math.min(buf.length, 65536)));
} catch {
return false;
}
return true;
}
/**
* Detect the stored extension for a namespace from content (never the client).
* Returns null when the content is not allowed in this namespace.
*/
export function detectCdnType(ns: string, buf: Uint8Array): CdnExt | null {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
if (kinds.includes("image")) {
const img = detectImageType(buf);
if (img) return img;
}
if (kinds.includes("text") && isTextContent(buf)) return "txt";
return null;
}
/** Human description of what a namespace accepts (for 415 errors). */
export function namespaceAccepts(ns: string): string {
const kinds = CDN_NAMESPACES[ns]?.kinds ?? [];
const parts: string[] = [];
if (kinds.includes("image")) parts.push("jpg/png/webp images");
if (kinds.includes("text")) parts.push("plain-text files");
return parts.join(" and ") || "nothing";
}
export async function removeCdnVariants(ns: string, key: string): Promise<void> {
const { unlink } = await import("node:fs/promises");
for (const ext of CDN_EXTS) {
try {
await unlink(cdnPath(ns, key, ext));
} catch {
// missing — fine
}
}
}
export async function writeCdnFile(ns: string, key: string, ext: CdnExt, data: Uint8Array): Promise<string> {
await mkdir(join(cdnDir(), ns), { recursive: true });
await removeCdnVariants(ns, key);
await Bun.write(cdnPath(ns, key, ext), data);
return `/cdn/${ns}/${key}.${ext}`;
}
// --- per-account upload rate limiter (sliding window) ---
const uploads = new Map<string, number[]>();
export function uploadAllowed(userId: string, ns: string): { ok: boolean; retryAfterSec: number } {
const cfg = CDN_NAMESPACES[ns]!;
const now = Date.now();
const k = `${ns}:${userId}`;
const list = (uploads.get(k) ?? []).filter((t) => t > now - cfg.windowMs);
uploads.set(k, list);
if (list.length >= cfg.maxUploads) {
const retryAfterSec = Math.ceil((list[0]! + cfg.windowMs - now) / 1000);
return { ok: false, retryAfterSec: Math.max(retryAfterSec, 1) };
}
return { ok: true, retryAfterSec: 0 };
}
export function recordUpload(userId: string, ns: string): void {
const k = `${ns}:${userId}`;
const list = uploads.get(k) ?? [];
list.push(Date.now());
uploads.set(k, list);
}
+94
View File
@@ -0,0 +1,94 @@
function env(name: string, fallback = ""): string {
return process.env[name] ?? fallback;
}
function envBool(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v === undefined) return fallback;
return v === "1" || v.toLowerCase() === "true";
}
function envInt(name: string, fallback: number): number {
const v = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(v) ? v : fallback;
}
export const config = {
// "production" under the NixOS module / CI builds, "development" locally.
nodeEnv: env("NODE_ENV", "development"),
isProduction: env("NODE_ENV", "development") === "production",
port: envInt("PORT", 3000),
host: env("HOST", "127.0.0.1"),
appPassword: env("APP_PASSWORD", ""),
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
dataDir: env("DATA_DIR", "./data"),
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
ollamaHost: env("OLLAMA_HOST", "http://localhost:11434"),
ollamaEnabled: envBool("OLLAMA_ENABLED", true),
mistralApiKey: env("MISTRAL_API_KEY", ""),
mistralEnabled: envBool("MISTRAL_ENABLED", false),
deepseekApiKey: env("DEEPSEEK_API_KEY", ""),
deepseekEnabled: envBool("DEEPSEEK_ENABLED", false),
anthropicApiKey: env("ANTHROPIC_API_KEY", ""),
anthropicEnabled: envBool("ANTHROPIC_ENABLED", false),
openaiApiKey: env("OPENAI_API_KEY", ""),
openaiEnabled: envBool("OPENAI_ENABLED", false),
geminiApiKey: env("GEMINI_API_KEY", ""),
geminiEnabled: envBool("GEMINI_ENABLED", false),
glmApiKey: env("GLM_API_KEY", ""),
glmEnabled: envBool("GLM_ENABLED", false),
// Zhipu has two platforms: international (api.z.ai, default) and
// mainland China (open.bigmodel.cn) — override for the latter.
glmBaseUrl: env("GLM_BASE_URL", "https://api.z.ai/api/paas/v4"),
// Arcaic web-UI backend (headless browser, throwaway profile per process).
arcaicEnabled: envBool("ARCAIC_ENABLED", false),
arcaicExecutable: env("ARCAIC_EXECUTABLE", "firefox"),
arcaicHeadless: envBool("ARCAIC_HEADLESS", true),
arcaicLoginTimeoutS: envInt("ARCAIC_LOGIN_TIMEOUT_S", 300),
arcaicResponseTimeoutS: envInt("ARCAIC_RESPONSE_TIMEOUT_S", 300),
// Public docs wiki (served by the hosted git forge, e.g. Gitea wiki pages).
// The app redirects GET /wiki there (302) so the target stays configurable.
wikiUrl: env("WIKI_URL", "https://git.kisakay.com/k/chat/wiki"),
// Per-driver debug logging to stdout ([driver:name] lines).
// On by default in development, off unless asked for in production.
driverDebug: envBool("DRIVER_DEBUG", !env("NODE_ENV", "development")),
// Local file CDN (avatars today, more namespaces tomorrow).
// Dev default is a folder next to the codebase; NixOS module points it
// under the service state dir.
cdnDir: env("CDN_DIR", "./cdn"),
// OCR tool (tesseract binary).
ocrLang: env("OCR_LANG", "eng"),
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
// Key recovery via email (optional — empty SMTP_HOST disables it).
smtpHost: env("SMTP_HOST", ""),
smtpPort: envInt("SMTP_PORT", 587),
smtpSecure: envBool("SMTP_SECURE", false), // true = implicit TLS (port 465)
smtpUser: env("SMTP_USER", ""),
smtpPass: env("SMTP_PASS", ""),
smtpFrom: env("SMTP_FROM", "KisAssistant <[email protected]>"),
appUrl: env("APP_URL", "http://localhost:3000").replace(/\/$/, ""),
resetTtlMs: envInt("RESET_TTL_MIN", 60) * 60_000,
};
export function assertConfig(): void {
if (!config.appPassword) {
console.error("FATAL: APP_PASSWORD is not set. Copy .env.example to .env and set it.");
process.exit(1);
}
}
+434
View File
@@ -0,0 +1,434 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { config } from "./config.ts";
// --- row types ---
export interface UserRow {
id: string;
username: string;
display_name: string;
avatar_url: string;
theme: string;
email: string;
key_hash: string;
created_at: number;
}
export interface ConversationRow {
id: string;
user_id: string;
title: string;
topic: string;
model: string;
archived_at: number;
created_at: number;
updated_at: number;
}
export interface MessageRow {
id: number;
conv_id: string;
role: string;
content: string;
created_at: number;
}
export interface SessionRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export interface PublicUser {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: string;
email: string;
createdAt: number;
isAdmin: boolean;
}
// --- db handle ---
let db: Database | null = null;
export function getDb(): Database {
if (db) return db;
mkdirSync(config.dataDir, { recursive: true });
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);
`);
// Email column added after the initial schema — keep idempotent.
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Archive flag added after the initial schema — keep idempotent.
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
if (!convCols.some((c) => c.name === "archived_at")) {
db.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
db.query(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
}
return db;
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
username: u.username,
displayName: u.display_name,
avatarUrl: u.avatar_url,
theme: u.theme,
email: u.email ?? "",
createdAt: u.created_at,
isAdmin: u.username === "admin",
};
}
// --- users ---
export function getUserByUsername(username: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
}
export function getUserById(id: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
}
export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
}
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
const d = getDb();
const row: UserRow = {
id: randomUUID(),
username: opts.username,
display_name: opts.displayName,
avatar_url: opts.avatarUrl,
theme: opts.theme,
email: opts.email,
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
}
return getUserById(id);
}
export function deleteUser(id: string): void {
const d = getDb();
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
const txn = d.transaction((cid: string) => {
delMsg.run(cid);
delShare.run(cid);
delConv.run(cid);
});
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM user_provider_keys WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
}
// --- sessions ---
export function createSession(tokenHash: string, userId: string, expiresAt: number): void {
const now = Date.now();
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt);
}
export function getSession(tokenHash: string): SessionRow | null {
const d = getDb();
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
if (!s) return null;
if (s.expires_at <= Date.now()) {
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
return null;
}
return s;
}
export function deleteSession(tokenHash: string): void {
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
}
export function deleteUserSessions(userId: string): void {
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
// --- conversations ---
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow {
const now = Date.now();
const row: ConversationRow = {
id: randomUUID(),
user_id: userId,
title: opts.title?.slice(0, 120) || "New chat",
topic: opts.topic?.slice(0, 120) || "",
model: opts.model?.slice(0, 160) || "",
archived_at: 0,
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at);
return row;
}
export function listConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
}
/** Archived chats: hidden from the sidebar, managed in settings. */
export function listArchivedConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
}
/**
* (Un)archive a conversation. archived_at is left out of updated_at on
* purpose so an unarchived chat returns to its chronological place.
*/
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null {
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id);
return getConversation(id);
}
export function getConversation(id: string): ConversationRow | null {
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
}
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null {
const d = getDb();
const sets: string[] = ["updated_at = ?"];
const vals: (string | number)[] = [Date.now()];
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); }
if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); }
d.query(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
return getConversation(id);
}
export function touchConversation(id: string, model?: string): void {
if (model) {
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id);
} else {
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);
}
}
export function deleteConversation(id: string): void {
const d = getDb();
const txn = d.transaction(() => {
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
d.query("DELETE FROM conversations WHERE id = ?").run(id);
});
txn();
}
export function addMessage(convId: string, role: string, content: string): void {
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
}
export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
}
// --- password-recovery resets (one-time, hashed, expiring) ---
export interface ResetRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
const d = getDb();
// One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
tokenHash, userId, Date.now(), expiresAt,
);
}
export function peekReset(tokenHash: string): ResetRow | null {
const d = getDb();
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
if (!r) return null;
if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return null;
}
return r;
}
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null {
const r = peekReset(tokenHash);
if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return r;
}
export function deleteUserResets(userId: string): void {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
}
// --- platform settings (admin-controlled feature flags) ---
export function getSetting(key: string, fallback: string): string {
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
return r?.value ?? fallback;
}
export function setSetting(key: string, value: string): void {
getDb().query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
).run(key, value, Date.now());
}
/** Public self-registration switch (default off = admin-created accounts only). */
export function isRegistrationEnabled(): boolean {
return getSetting("registration_enabled", "0") === "1";
}
/** Admin kill-switch for the OCR platform tool (default on). */
export function isOcrToolEnabled(): boolean {
return getSetting("tools_ocr_enabled", "1") === "1";
}
// --- public shares ---
export function ensureShare(convId: string): string {
const d = getDb();
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
if (existing) return existing.public_id;
for (let i = 0; i < 5; i++) {
const publicId = randomBytes(9).toString("base64url");
try {
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now());
return publicId;
} catch {
// collision — retry
}
}
throw new Error("could not generate share id");
}
export function getShareByConv(convId: string): string | null {
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
return r?.public_id ?? null;
}
export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
}
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null {
const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null;
const conv = getConversation(s.conv_id);
if (!conv) return null;
const author = getUserById(conv.user_id);
return {
conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone",
authorAvatarUrl: author?.avatar_url || "",
archived: conv.archived_at !== 0,
sharedAt: s.created_at,
};
}
+124
View File
@@ -0,0 +1,124 @@
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
import { DriverDisabledError } from "./types.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
/**
* Base class for OpenAI-compatible HTTP API drivers
* (mistral / glm / deepseek / openai / anthropic adapter).
* Only enabled when an API key is configured. Phase 1: disabled stubs.
*/
export abstract class ApiDriverBase implements LLMDriver {
abstract readonly name: string;
readonly enabled: boolean;
protected baseUrl: string;
protected apiKey: string;
protected defaultModels: string[];
constructor(opts: { baseUrl: string; apiKey: string; enabled: boolean; defaultModels: string[] }) {
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
this.apiKey = opts.apiKey;
this.enabled = opts.enabled && opts.apiKey.length > 0;
this.defaultModels = opts.defaultModels;
}
protected ensureEnabled(): void {
if (!this.enabled) throw new DriverDisabledError(this.name);
}
async listModels(): Promise<DriverModel[]> {
this.ensureEnabled();
// Try /models endpoint, fall back to static list.
try {
const res = await fetch(`${this.baseUrl}/models`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
signal: AbortSignal.timeout(8000),
});
if (res.ok) {
const data = (await res.json()) as { data?: { id: string }[] };
if (Array.isArray(data.data) && data.data.length > 0) {
driverLog(this.name, `listModels -> ${data.data.length} model(s) via /models`);
return data.data.map((m) => ({ id: `${this.name}:${m.id}`, name: m.id, driver: this.name, label: m.id }));
}
}
} catch (e) {
driverLog(this.name, `listModels: /models failed (${(e as Error).message}), using static list`);
}
return this.defaultModels.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: m }));
}
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
this.ensureEnabled();
let out = "";
for await (const t of this.chatStream(messages, opts)) out += t;
return out;
}
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
this.ensureEnabled();
const t0 = Date.now();
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify({ model: opts.model, messages, stream: true }),
signal: opts.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
throw new Error(`${this.name} chat failed (${res.status}): ${text.slice(0, 300)}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let chunks = 0;
let chars = 0;
let status = "done";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop() ?? "";
for (const ev of events) {
for (const line of ev.split("\n")) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (payload === "[DONE]") {
driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
return;
}
try {
const obj = JSON.parse(payload) as { choices?: { delta?: { content?: string } }[] };
const token = obj.choices?.[0]?.delta?.content ?? "";
if (token) {
chunks++;
chars += token.length;
opts.onToken?.(token);
yield token;
}
} catch {
continue;
}
}
}
}
driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
} catch (e) {
status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`;
throw e;
} finally {
try {
await reader.cancel();
} catch {
// already closed / consumed — fine
}
reader.releaseLock();
if (status !== "done") {
driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { config } from "../config.ts";
import { ApiDriverBase } from "./apiBase.ts";
/**
* Concrete API drivers read their key from the server env by default.
* Pass `apiKeyOverride` to build a per-user (BYOK) instance instead: the
* driver is then enabled whenever the override key is non-empty, ignoring
* the global `*_ENABLED` flag. See `src/userProviders.ts`.
*/
function globalEnabled(flag: boolean, key: string): boolean {
return flag && key.length > 0;
}
/** Mistral API driver. Global priority #2 (after local ollama). */
export class MistralDriver extends ApiDriverBase {
readonly name = "mistral";
constructor(apiKeyOverride?: string) {
const apiKey = apiKeyOverride ?? config.mistralApiKey;
super({
baseUrl: "https://api.mistral.ai/v1",
apiKey,
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.mistralEnabled, apiKey),
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
});
}
}
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
export class DeepSeekDriver extends ApiDriverBase {
readonly name = "deepseek";
constructor(apiKeyOverride?: string) {
const apiKey = apiKeyOverride ?? config.deepseekApiKey;
super({
baseUrl: "https://api.deepseek.com/v1",
apiKey,
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.deepseekEnabled, apiKey),
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
});
}
}
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
export class AnthropicDriver extends ApiDriverBase {
readonly name = "anthropic";
constructor(apiKeyOverride?: string) {
const apiKey = apiKeyOverride ?? config.anthropicApiKey;
super({
baseUrl: "https://api.anthropic.com/v1",
apiKey,
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.anthropicEnabled, apiKey),
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
});
}
}
/** OpenAI API driver. Priority: last among APIs. */
export class OpenAIDriver extends ApiDriverBase {
readonly name = "openai";
constructor(apiKeyOverride?: string) {
const apiKey = apiKeyOverride ?? config.openaiApiKey;
super({
baseUrl: "https://api.openai.com/v1",
apiKey,
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.openaiEnabled, apiKey),
defaultModels: ["gpt-4o", "gpt-4o-mini"],
});
}
}
/**
* Gemini driver (Google AI, OpenAI-compatible endpoint). Priority: after GLM.
* Docs: https://ai.google.dev/gemini-api/docs/openai — key from
* https://aistudio.google.com/apikey (server: GEMINI_API_KEY, or per-user
* BYOK in settings).
*/
export class GeminiDriver extends ApiDriverBase {
readonly name = "gemini";
constructor(apiKeyOverride?: string) {
const apiKey = apiKeyOverride ?? config.geminiApiKey;
super({
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
apiKey,
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.geminiEnabled, apiKey),
defaultModels: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
});
}
}
/**
* GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral.
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
* uses https://open.bigmodel.cn/api/paas/v4 (via GLM_BASE_URL).
*/
export class GlmDriver extends ApiDriverBase {
readonly name = "glm";
constructor() {
super({
baseUrl: config.glmBaseUrl,
apiKey: config.glmApiKey,
enabled: config.glmEnabled,
defaultModels: ["glm-4.7", "glm-4.6", "glm-4.5-air", "glm-4.5-flash"],
});
}
}
+195
View File
@@ -0,0 +1,195 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
import {
ArcaicBrowserEngine,
GEMINI_SITE,
OPENAI_SITE,
QWEN_SITE,
type BrowserSite,
} from "./browser.ts";
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
import { DriverDisabledError } from "./types.ts";
export interface ArcaicSpec {
key: string;
label: string;
site: BrowserSite;
}
const SPECS: ArcaicSpec[] = [
{ key: "arcaic-openai", label: "arcaic-openai", site: OPENAI_SITE },
{ key: "arcaic-gemini", label: "arcaic-gemini", site: GEMINI_SITE },
{ key: "arcaic-qwen", label: "arcaic-qwen", site: QWEN_SITE },
];
/**
* Flatten a KisAssistant conversation into one prompt for the Arcaic web
* composer: system text as a preamble, earlier turns as a transcript, and
* the latest user message sent as-is.
*/
export function flattenMessages(messages: ChatMessage[]): string {
const system = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n")
.trim();
const rest = messages.filter((m) => m.role !== "system");
const last = rest[rest.length - 1];
const earlier = rest.slice(0, -1);
const parts: string[] = [];
if (system) parts.push(`[Instructions]\n${system}\n`);
if (earlier.length) {
parts.push("[Conversation so far]");
for (const m of earlier) parts.push(`${m.role === "user" ? "User" : "Assistant"}: ${m.content}`);
parts.push("[End of conversation so far]\n");
}
if (last) parts.push(last.content);
return parts.join("\n");
}
/** Buffers tokens produced by a push-style callback into a pull-style iterator. */
class AsyncTokenQueue implements AsyncIterable<string> {
private buf: string[] = [];
private error: Error | null = null;
private ended = false;
private waiters: Array<(r: IteratorResult<string> | null) => void> = [];
push(token: string): void {
const w = this.waiters.shift();
if (w) w({ value: token, done: false });
else this.buf.push(token);
}
end(err?: Error): void {
if (err) this.error = err;
this.ended = true;
for (const w of this.waiters.splice(0)) w(null);
}
async next(): Promise<IteratorResult<string>> {
for (;;) {
if (this.buf.length) return { value: this.buf.shift() as string, done: false };
if (this.ended) {
if (this.error) throw this.error;
return { value: undefined, done: true };
}
const wake = await new Promise<IteratorResult<string> | null>((resolve) => this.waiters.push(resolve));
if (wake) return wake;
}
}
[Symbol.asyncIterator](): AsyncIterator<string> {
return this;
}
}
/**
* One Arcaic sub-driver: a named web-UI backend (openai / gemini / qwen)
* sharing the throwaway-browser engine logic. Each sub-driver owns its
* engine — one browser + one temp profile per backend.
*/
export class ArcaicSubDriver implements LLMDriver {
readonly name: string;
readonly enabled = config.arcaicEnabled;
private readonly spec: ArcaicSpec;
private engine: ArcaicBrowserEngine | null = null;
constructor(spec: ArcaicSpec) {
this.spec = spec;
this.name = spec.key;
if (this.enabled) driverLog(this.name, "enabled (web UI session, throwaway profile, headless)");
}
private ensureEnabled(): void {
if (!this.enabled) throw new DriverDisabledError(this.name);
}
private getEngine(): ArcaicBrowserEngine {
this.ensureEnabled();
if (!this.engine) {
this.engine = new ArcaicBrowserEngine({
executablePath: resolveExecutable(),
headless: config.arcaicHeadless,
loginTimeoutMs: config.arcaicLoginTimeoutS * 1000,
responseTimeoutMs: config.arcaicResponseTimeoutS * 1000,
});
}
return this.engine;
}
async listModels(): Promise<DriverModel[]> {
this.ensureEnabled();
return [
{
id: `${this.name}:chat`,
name: "chat",
driver: this.name,
label: this.spec.label,
group: "arcaic",
},
];
}
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
this.ensureEnabled();
const t0 = Date.now();
const text = await this.getEngine().send(flattenMessages(messages), {
site: this.spec.site,
signal: opts.signal,
fresh: true,
onLoginHint: () => driverLog(this.name, "session not ready — waiting for it inside the browser window"),
});
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
return text;
}
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
this.ensureEnabled();
const t0 = Date.now();
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
const queue = new AsyncTokenQueue();
let chars = 0;
let jobDone = false;
const job = this.getEngine()
.send(flattenMessages(messages), {
site: this.spec.site,
signal: opts.signal,
fresh: true,
onLoginHint: () => driverLog(this.name, "session not ready — waiting for it inside the browser window"),
onToken: (tok) => {
chars += tok.length;
opts.onToken?.(tok);
queue.push(tok);
},
})
.then(
() => {
jobDone = true;
queue.end();
},
(e) => {
jobDone = true;
queue.end(e as Error);
},
);
try {
yield* queue;
} finally {
if (!jobDone) this.getEngine().abortCurrent();
void job;
}
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
export function createArcaicDrivers(): ArcaicSubDriver[] {
return SPECS.map((spec) => new ArcaicSubDriver(spec));
}
function resolveExecutable(): string {
const conf = config.arcaicExecutable;
if (conf.includes("/")) return conf;
const found = Bun.which(conf);
if (!found) throw new Error("Arcaic backend is not configured (browser executable missing) — contact the administrator");
return found;
}
+476
View File
@@ -0,0 +1,476 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser, Page } from "puppeteer-core";
import { driverLog } from "./log.ts";
export interface BrowserSite {
key: string;
url: string;
composer: string[];
submit: "button" | "enter";
sendButton?: string;
/** Completion signal (counted visible, before vs after send). */
copyButton: string;
/** Restrict the copy-button count to this container (excludes user messages). */
copyScope?: string;
/** Latest assistant message container (last match wins). */
messageContainer: string;
/** Answer-text nodes inside the container (joined); container innerText if unset. */
answer?: string;
/**
* How completion is detected. "copy-button": a newly visible copy control.
* "aria-busy": the response container reports aria-busy="false" (plus text).
*/
completion?: "copy-button" | "aria-busy";
acceptTexts?: string[];
}
export const OPENAI_SITE: BrowserSite = {
key: "openai",
url: "https://chatgpt.com/",
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
submit: "button",
sendButton: "button[data-composer-submit]",
copyButton: "button[data-copy-message]",
messageContainer: "div[data-assistant-markdown]",
answer: "[data-assistant-stream-block]",
completion: "copy-button",
};
export const QWEN_SITE: BrowserSite = {
key: "qwen",
url: "https://chat.qwen.ai/",
composer: ["textarea.message-input-textarea", "textarea[placeholder='Ask Qwen']"],
submit: "enter",
copyButton: "button[aria-label='Copy']",
copyScope: ".qwen-chat-message-assistant",
messageContainer: ".qwen-chat-message-assistant",
answer: ".custom-qwen-markdown",
completion: "copy-button",
};
export const GEMINI_SITE: BrowserSite = {
key: "gemini",
url: "https://gemini.google.com/app",
composer: [
"div[aria-label='Enter a prompt for Gemini']",
"div.ql-editor[contenteditable='true']",
"[data-test-id='textarea-inner'] div[contenteditable='true']",
],
submit: "enter",
copyButton: "button[aria-label='Copy']",
messageContainer: "structured-content-container",
answer: ".markdown",
completion: "aria-busy",
};
export const SITES: Record<string, BrowserSite> = {
openai: OPENAI_SITE,
qwen: QWEN_SITE,
gemini: GEMINI_SITE,
};
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export interface ChatGPTBrowserOptions {
/** Firefox binary (puppeteer-core requires an explicit executable). */
executablePath: string;
headless: boolean;
/** Grace period for a manual login before giving up on the composer. */
loginTimeoutMs: number;
responseTimeoutMs: number;
pollMs?: number;
}
export interface ChatGPTSendCallbacks {
site: BrowserSite;
signal?: AbortSignal;
onToken?: (delta: string) => void;
onLoginHint?: () => void;
/** Start a fresh conversation before sending. */
fresh?: boolean;
}
/** Session not ready: the web UI never became interactive in time. */
export class BrowserLoginError extends Error {
constructor(timeoutMs: number) {
super(`Arcaic backend is not ready (no session after ${Math.round(timeoutMs / 1000)}s) — try again in a moment`);
this.name = "BrowserLoginError";
}
}
/**
* Throwaway Firefox session driving web-UI backends via puppeteer-core
* (WebDriver BiDi). Each launch creates a fresh mkdtemp profile that is
* removed on close(): zero persistence between runs. One engine = one
* browser + one tab; the per-send site config selects the target
* (chatgpt.com, chat.qwen.ai, gemini.google.com/app).
*/
export class ArcaicBrowserEngine {
private browser: Browser | null = null;
private page: Page | null = null;
private profileDir: string | null = null;
private opening: Promise<void> | null = null;
private closed = false;
private chain: Promise<unknown> = Promise.resolve();
private currentAbort: AbortController | null = null;
constructor(private readonly opts: ChatGPTBrowserOptions) {}
get isOpen(): boolean {
return this.browser !== null;
}
async open(site: BrowserSite): Promise<void> {
if (this.closed) throw new Error("engine already closed");
if (!this.browser) {
this.opening ??= this.launch().finally(() => {
this.opening = null;
});
await this.opening;
}
const page = this.requirePage();
if (!page.url().startsWith(site.url)) {
await this.gotoSite(page, site);
await this.dismissCookieBanner(page, site, 10_000);
}
}
/** One-shot state report (used by the POC probe mode). */
async probe(site: BrowserSite): Promise<{ title: string; url: string; composer: string | null }> {
await this.open(site);
const page = this.requirePage();
await sleep(3000);
return { title: await page.title(), url: page.url(), composer: await this.findComposer(page, site) };
}
/**
* Type a prompt into the composer, submit, stream the answer through
* onToken and resolve with the final text. Serialized: one send at a time.
*/
send(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
const task = this.chain
.catch(() => undefined)
.then(() => this.doSend(prompt, cb));
this.chain = task.catch(() => undefined);
return task;
}
abortCurrent(): void {
this.currentAbort?.abort();
}
async close(): Promise<void> {
this.closed = true;
this.abortCurrent();
const browser = this.browser;
this.browser = null;
this.page = null;
if (browser) {
try {
await browser.close();
} catch {
// already gone
}
}
const dir = this.profileDir;
this.profileDir = null;
if (dir) {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
driverLog("arcaic", `temporary profile removed (${dir})`);
}
}
private async launch(): Promise<void> {
const puppeteer = await import("puppeteer-core");
this.profileDir = await mkdtemp(join(tmpdir(), "ka-arcaic-"));
driverLog("arcaic", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
const browser = await puppeteer.launch({
browser: "firefox",
executablePath: this.opts.executablePath,
headless: this.opts.headless,
userDataDir: this.profileDir,
defaultViewport: null,
protocolTimeout: 300_000,
});
browser.once("disconnected", () => {
if (this.browser === browser) {
this.browser = null;
this.page = null;
driverLog("arcaic", "browser disconnected unexpectedly");
}
});
this.browser = browser;
this.page = await browser.newPage();
}
/** Target site stalls on domcontentloaded sometimes — retry a few times. */
private async gotoSite(page: Page, site: BrowserSite): Promise<void> {
let lastErr: unknown = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(site.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
return;
} catch (e) {
lastErr = e;
driverLog("arcaic", `${site.key}: navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
await sleep(3000);
}
}
throw lastErr instanceof Error ? lastErr : new Error("navigation to the Arcaic backend failed — try again");
}
/**
* Fresh profile = cookie consent banner on first load. It swallows input
* until accepted.
*/
private async dismissCookieBanner(page: Page, site: BrowserSite, waitMs: number): Promise<void> {
const accept = site.acceptTexts ?? ["accept all", "accept"];
const deadline = Date.now() + waitMs;
for (;;) {
const clicked = await page
.evaluate((texts: string[]) => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
const t = (b.textContent ?? "").trim().toLowerCase();
return texts.includes(t) && (b as HTMLElement).getClientRects().length > 0;
});
if (!btn) return false;
(btn as HTMLElement).click();
return true;
}, accept)
.catch(() => false);
if (clicked) {
driverLog("arcaic", `${site.key}: cookie banner dismissed`);
await sleep(800);
return;
}
if (Date.now() >= deadline) return;
await sleep(1000);
}
}
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
if (this.closed) throw new Error("engine already closed");
const site = cb.site;
const pollMs = this.opts.pollMs ?? 250;
const ac = new AbortController();
this.currentAbort = ac;
const forward = () => ac.abort();
cb.signal?.addEventListener("abort", forward);
const t0 = Date.now();
try {
await this.open(site);
const page = this.requirePage();
if (cb.fresh) {
await this.gotoSite(page, site);
await this.dismissCookieBanner(page, site, 1_500);
}
const composer = await this.waitForComposer(page, site, cb.onLoginHint);
const copyBefore = await this.countVisible(page, site);
await this.typeIntoComposer(page, composer, prompt);
await this.submitMessage(page, site);
driverLog("arcaic", `${site.key}: sent ${prompt.length} chars via ${composer}, waiting for response`);
let last = "";
let stableTicks = 0;
const deadline = Date.now() + this.opts.responseTimeoutMs;
for (;;) {
if (ac.signal.aborted) {
await this.stopGenerating(page, site);
throw new DOMException("Arcaic backend request aborted", "AbortError");
}
const read = await this.readAnswer(page, site).catch(() => ({ text: "", busy: null as string | null }));
if (!read.text) {
stableTicks++;
} else if (read.text !== last) {
if (read.text.length > last.length && read.text.startsWith(last)) {
const delta = read.text.slice(last.length);
last = read.text;
cb.onToken?.(delta);
} else {
last = read.text;
}
stableTicks = 0;
} else {
stableTicks++;
}
let signaled: boolean;
if (site.completion === "aria-busy") {
signaled =
read.busy === null
? last.length > 0 && stableTicks >= 3
: last.length > 0 && read.busy !== "true";
} else {
signaled = (await this.countVisible(page, site)) > copyBefore;
}
if (signaled && stableTicks >= 1) {
const finalText = (await this.readAnswer(page, site).catch(() => null))?.text ?? "";
if (finalText.length > last.length) cb.onToken?.(finalText.slice(last.length));
const out = finalText.length ? finalText : last;
driverLog("arcaic", `${site.key}: response complete: ${out.length} chars in ${fmtS(Date.now() - t0)}`);
return out;
}
if (Date.now() > deadline) {
await this.stopGenerating(page, site);
throw new Error(`Arcaic backend timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s — try again`);
}
await sleep(pollMs);
}
} finally {
cb.signal?.removeEventListener("abort", forward);
if (this.currentAbort === ac) this.currentAbort = null;
}
}
private async waitForComposer(page: Page, site: BrowserSite, hint?: () => void): Promise<string> {
const deadline = Date.now() + this.opts.loginTimeoutMs;
let hinted = false;
for (;;) {
const sel = await this.findComposer(page, site);
if (sel) return sel;
if (!hinted) {
hinted = true;
hint?.();
}
if (Date.now() > deadline) throw new BrowserLoginError(this.opts.loginTimeoutMs);
await sleep(1000);
}
}
private async findComposer(page: Page, site: BrowserSite): Promise<string | null> {
for (const sel of site.composer) {
try {
if (await page.$(sel)) return sel;
} catch {
// navigation in flight — retry on next poll
}
}
return null;
}
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
const input = await page.$(sel);
if (!input) throw new Error("Arcaic backend session error — try again");
await input.focus();
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i]) await page.keyboard.type(lines[i], { delay: 4 });
if (i < lines.length - 1) {
await page.keyboard.down("Shift");
await page.keyboard.press("Enter");
await page.keyboard.up("Shift");
await sleep(30);
}
}
}
private async composerHasText(page: Page, sel: string): Promise<boolean> {
try {
const text = await page.$eval(sel, (el) => {
const v = (el as HTMLTextAreaElement).value;
if (typeof v === "string") return v;
return (el as HTMLElement).innerText ?? "";
});
return text.trim().length > 0;
} catch {
return false;
}
}
private async submitMessage(page: Page, site: BrowserSite): Promise<void> {
if (site.submit === "button" && site.sendButton) {
await this.waitSendable(page, site);
await page.click(site.sendButton);
return;
}
if (!(await this.composerHasText(page, site.composer[0]))) {
throw new Error("Arcaic backend is not responding (send failed) — try again");
}
await page.keyboard.press("Enter");
}
private async waitSendable(page: Page, site: BrowserSite): Promise<void> {
if (!site.sendButton) return;
const sendButton = site.sendButton;
const deadline = Date.now() + 10_000;
for (;;) {
const ok = await page
.$eval(sendButton, (el) => {
const b = el as HTMLButtonElement;
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
})
.catch(() => false);
if (ok) return;
if (Date.now() > deadline) throw new Error("Arcaic backend is not responding (send failed) — try again");
await sleep(250);
}
}
private async stopGenerating(page: Page, site: BrowserSite): Promise<void> {
if (site.submit !== "button" || !site.sendButton) return;
try {
await page.click(site.sendButton);
} catch {
// button already back in send state — nothing to stop
}
}
private async countVisible(page: Page, site: BrowserSite): Promise<number> {
try {
if (site.copyScope) {
const scope = site.copyScope;
const sel = site.copyButton;
return await page.$$eval(
scope,
(els, s: string) => {
let n = 0;
for (const el of els) {
n += Array.from(el.querySelectorAll(s)).filter((b) => (b as HTMLElement).getClientRects().length > 0)
.length;
}
return n;
},
sel,
);
}
return await page.$$eval(site.copyButton, (els) =>
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
);
} catch {
return 0;
}
}
private async readAnswer(page: Page, site: BrowserSite): Promise<{ text: string; busy: string | null }> {
return await page.$$eval(
site.messageContainer,
(els, answerSel: string) => {
const last = els[els.length - 1];
if (!last) return { text: "", busy: null as string | null };
const el = last as HTMLElement;
const busyEl = el.querySelector("[aria-busy]");
const busy = busyEl ? busyEl.getAttribute("aria-busy") : null;
if (!answerSel) return { text: el.innerText ?? "", busy };
const parts = Array.from(el.querySelectorAll(answerSel));
if (parts.length === 0) return { text: el.innerText ?? "", busy };
return {
text: parts
.map((b) => (b as HTMLElement).innerText.trim())
.filter(Boolean)
.join("\n\n"),
busy,
};
},
site.answer ?? "",
);
}
private requirePage(): Page {
if (!this.page) throw new Error("browser session is not open");
return this.page;
}
}
function fmtS(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
+18
View File
@@ -0,0 +1,18 @@
import { config } from "../config.ts";
/** Concise per-driver debug logging to stdout (toggle with DRIVER_DEBUG). */
export function driverLog(driver: string, msg: string): void {
if (!config.driverDebug) return;
const t = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
console.log(`[${t}][driver:${driver}] ${msg}`);
}
export function fmtBytes(n: number): string {
if (n < 1024) return `${n}B`;
return `${(n / 1024).toFixed(1)}KB`;
}
export function fmtMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
+111
View File
@@ -0,0 +1,111 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
interface OllamaTag {
name: string;
}
/** Local LLM via Ollama. Priority #1. */
export class OllamaDriver implements LLMDriver {
readonly name = "ollama";
readonly enabled: boolean;
constructor(public readonly host: string = config.ollamaHost, enabled = config.ollamaEnabled) {
this.host = host.replace(/\/$/, "");
this.enabled = enabled;
}
async listModels(): Promise<DriverModel[]> {
const t0 = Date.now();
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) {
driverLog(this.name, `listModels ERROR: /api/tags -> ${res.status} (${fmtMs(Date.now() - t0)})`);
throw new Error(`ollama /api/tags failed: ${res.status}`);
}
const data = (await res.json()) as { models?: OllamaTag[] };
const models = data.models ?? [];
driverLog(this.name, `listModels -> ${models.length} model(s) [${models.map((m) => m.name).join(", ")}] (${fmtMs(Date.now() - t0)})`);
return models.map((m) => ({
id: `ollama:${m.name}`,
name: m.name,
driver: "ollama",
label: m.name,
}));
}
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
let out = "";
for await (const t of this.chatStream(messages, opts)) out += t;
return out;
}
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
const t0 = Date.now();
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
const res = await fetch(`${this.host}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: opts.model, messages, stream: true }),
signal: opts.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let chunks = 0;
let chars = 0;
let status = "done";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean };
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
const token = obj.message?.content ?? "";
if (token) {
chunks++;
chars += token.length;
opts.onToken?.(token);
yield token;
}
if (obj.done) {
driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
return;
}
} catch (e) {
if (e instanceof SyntaxError) continue; // partial line, ignore
throw e;
}
}
}
driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
} catch (e) {
status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`;
throw e;
} finally {
// Stop the upstream generation when the consumer goes away
// (client disconnect) instead of leaking it in the background.
try {
await reader.cancel();
} catch {
// already closed / consumed — fine
}
reader.releaseLock();
if (status !== "done") {
driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
import { OllamaDriver } from "./ollama.ts";
import { createArcaicDrivers } from "./arcaic.ts";
import type { DriverModel, LLMDriver } from "./types.ts";
/**
* Global driver priority (per spec):
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. gemini
* -> 5. arcaic-openai -> 6. arcaic-gemini -> 7. arcaic-qwen
* -> 8. deepseek -> 9. anthropic -> 10. openai
*/
const PRIORITY = [
"ollama",
"mistral",
"glm",
"gemini",
"arcaic-openai",
"arcaic-gemini",
"arcaic-qwen",
"deepseek",
"anthropic",
"openai",
];
/** How long the user-facing model list stays in memory before re-hitting upstream. */
export const MODEL_CACHE_TTL_MS = 60_000;
export class DriverRegistry {
private drivers: Map<string, LLMDriver>;
// In-memory model list cache: user requests are served from here so we
// don't hit Ollama (/api/tags) on every login. Refreshed at most once
// per MODEL_CACHE_TTL_MS; admins can force a refresh.
private modelsCache: { models: DriverModel[]; at: number } | null = null;
private modelsInflight: Promise<DriverModel[]> | null = null;
constructor() {
const all: LLMDriver[] = [
new OllamaDriver(),
new MistralDriver(),
new GlmDriver(),
new GeminiDriver(),
...createArcaicDrivers(),
new DeepSeekDriver(),
new AnthropicDriver(),
new OpenAIDriver(),
];
this.drivers = new Map(all.map((d) => [d.name, d]));
}
/** Drivers in priority order (enabled ones only if flag set). */
ordered(onlyEnabled = false): LLMDriver[] {
const list = PRIORITY.map((n) => this.drivers.get(n)!).filter(Boolean);
return onlyEnabled ? list.filter((d) => d.enabled) : list;
}
get(name: string): LLMDriver | undefined {
return this.drivers.get(name);
}
/** List models across all enabled drivers. Never throws: a failing driver is skipped. */
async listAllModels(): Promise<DriverModel[]> {
const out: DriverModel[] = [];
for (const d of this.ordered(true)) {
try {
out.push(...(await d.listModels()));
} catch (e) {
console.error(`[drivers] ${d.name} listModels failed:`, (e as Error).message);
}
}
return out;
}
/**
* Cached variant of listAllModels for user-facing requests.
* - Serves the in-memory list when fresher than MODEL_CACHE_TTL_MS.
* - Otherwise refetches upstream once (concurrent callers share the
* same in-flight promise instead of hammering Ollama).
* - `force=true` bypasses the cache (admin "Ollama models" view).
*/
async listAllModelsCached(force = false): Promise<DriverModel[]> {
const now = Date.now();
if (!force && this.modelsCache && now - this.modelsCache.at < MODEL_CACHE_TTL_MS) {
return this.modelsCache.models;
}
if (!force && this.modelsInflight) return this.modelsInflight;
const p = this.listAllModels().then((models) => {
this.modelsCache = { models, at: Date.now() };
return models;
}).finally(() => {
if (this.modelsInflight === p) this.modelsInflight = null;
});
// Only share the promise for non-forced loads; a forced admin refresh
// always goes upstream even if a user load is in flight.
if (!force) this.modelsInflight = p;
return p;
}
/** Drop the cached model list (e.g. after an admin pull/delete). */
invalidateModelsCache(): void {
this.modelsCache = null;
}
/**
* Split a fully-qualified "driver:model" id.
* "ollama:llama3.1:8b" -> driver "ollama", model "llama3.1:8b".
*/
splitId(fullId: string): { driver: LLMDriver; model: string } {
const idx = fullId.indexOf(":");
if (idx === -1) throw new Error(`invalid model id "${fullId}" (expected "driver:model")`);
const driverName = fullId.slice(0, idx);
const model = fullId.slice(idx + 1);
const driver = this.drivers.get(driverName);
if (!driver) throw new Error(`unknown driver "${driverName}"`);
if (!driver.enabled) throw new Error(`driver "${driverName}" is disabled`);
if (!model) throw new Error(`empty model name in "${fullId}"`);
return { driver, model };
}
}
+44
View File
@@ -0,0 +1,44 @@
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface DriverModel {
/** Fully-qualified id, e.g. "ollama:llama3.1" */
id: string;
/** Short model name, e.g. "llama3.1" */
name: string;
/** Driver name, e.g. "ollama" */
driver: string;
label: string;
/** Display category in the model picker (defaults to `driver`). */
group?: string;
}
export interface ChatOptions {
model: string; // short name within the driver
signal?: AbortSignal;
onToken?: (token: string) => void;
}
/**
* Driver interface (class-oriented as per spec).
* Each LLM backend (ollama, mistral, puppeteer, ...) implements this.
*/
export interface LLMDriver {
readonly name: string;
readonly enabled: boolean;
/** List models exposed by this driver. */
listModels(): Promise<DriverModel[]>;
/** Non-streaming chat completion. */
chat(messages: ChatMessage[], opts: ChatOptions): Promise<string>;
/** Streaming chat completion (SSE tokens). */
chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void>;
}
export class DriverDisabledError extends Error {
constructor(driver: string) {
super(`driver "${driver}" is disabled / not configured`);
this.name = "DriverDisabledError";
}
}
+1485
View File
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
import type { Transporter } from "nodemailer";
import { config } from "./config.ts";
let transporter: Transporter | null = null;
let warned = false;
export function mailEnabled(): boolean {
return config.smtpHost.length > 0;
}
/** Sanitized snapshot for the Admin Center mail viewer (admin-only route). */
export function smtpStatus(): {
enabled: boolean;
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
hasPass: boolean;
from: string;
appUrl: string;
} {
return {
enabled: mailEnabled(),
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
user: config.smtpUser,
pass: config.smtpPass,
hasPass: config.smtpPass.length > 0,
from: config.smtpFrom,
appUrl: config.appUrl,
};
}
/** Verify SMTP connectivity (connect + auth, no mail sent). Throws on failure. */
export async function verifySmtp(): Promise<void> {
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
const mailer = await loadMailer();
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
const t = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
try {
await t.verify();
} finally {
t.close();
}
}
/** Send a connectivity test mail to `to`. Throws on failure, returns messageId. */
export async function sendSmtpTestMail(to: string): Promise<string> {
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
const mailer = await loadMailer();
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
const t = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
try {
const info = await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — SMTP test",
text: "This is a KisAssistant SMTP connectivity test. If you received it, outgoing mail works.",
html: `<p>This is a <strong>KisAssistant</strong> SMTP connectivity test. If you received it, outgoing mail works.</p>`,
});
return info.messageId ?? "";
} finally {
t.close();
}
}
// Lazy import: the backend boots (and serves) fine without node_modules
// installed; SMTP is only needed when actually sending.
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
try {
return await import("nodemailer");
} catch {
if (!warned) {
warned = true;
console.error("[mail] nodemailer not installed — run `bun install` for email support");
}
return null;
}
}
async function getTransporter(): Promise<Transporter | null> {
if (!mailEnabled()) return null;
if (!transporter) {
const mailer = await loadMailer();
if (!mailer) return null;
transporter = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
}
return transporter;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Shared branded layout for ticket emails. Every mail states the same rule:
* email is notification-only — real replies happen on the /review page.
*/
function ticketLayout(title: string, bodyHtml: string, reviewUrl: string): { html: string; text: string } {
const text = bodyHtml
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "");
const html =
`<!doctype html><html><body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">` +
`<div style="max-width:560px;margin:0 auto;padding:24px 16px;">` +
`<div style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e7e5e4;">` +
`<div style="background:#059669;padding:20px 24px;color:#ffffff;">` +
`<div style="font-size:20px;font-weight:700;">KisAssistant</div>` +
`<div style="font-size:13px;opacity:.85;">${escHtml(title)}</div>` +
`</div>` +
`<div style="padding:24px;color:#1c1917;font-size:14px;line-height:1.6;">${bodyHtml}</div>` +
`<div style="padding:16px 24px;background:#fafaf9;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;">` +
`This email is notification-only — please do not reply to it.<br>` +
`Respond on the official site: <a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a>` +
`</div></div></div></body></html>`;
return { html, text: `${text}\n\n--\nThis email is notification-only, do not reply. Respond at: ${reviewUrl}\n` };
}
async function sendTicketMail(to: string, subject: string, title: string, bodyHtml: string, reviewUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
console.error("[mail] SMTP_HOST not set — ticket email skipped");
return false;
}
try {
const { html, text } = ticketLayout(title, bodyHtml, reviewUrl);
await t.sendMail({ from: config.smtpFrom, to, subject, text, html });
console.log(`[mail] ticket email "${subject}" sent`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
/** Confirm a wishlist request was received, with the /review ticket link. */
export async function sendAccessReceivedEmail(to: string, username: string, reviewUrl: string): Promise<boolean> {
return sendTicketMail(
to,
"KisAssistant — access request received",
"Access request received",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>We received your request for a KisAssistant account. An admin will review it shortly.</p>` +
`<p>Follow the discussion and add arguments here:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>` +
`<p>Keep this link — it is your personal ticket page.</p>`,
reviewUrl,
);
}
/** Notify status changes. `key` is set only on acceptance (shown once). */
export async function sendAccessStatusEmail(
to: string,
username: string,
status: "reviewing" | "accepted" | "refused",
reason: string,
reviewUrl: string,
key?: string,
): Promise<boolean> {
if (status === "accepted") {
return sendTicketMail(
to,
"KisAssistant — access granted",
"Access granted",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.</p>` +
`<p>Your access key (shown <strong>once</strong> — save it now):</p>` +
`<p style="background:#f5f5f4;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:15px;">${escHtml(key ?? "")}</p>` +
`<p>Log in with your username and this key. You can still discuss on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
if (status === "refused") {
return sendTicketMail(
to,
"KisAssistant — access request refused",
"Access refused",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Your request for a KisAssistant account was refused${reason ? ` for this reason: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You may still reply on your ticket page if you think this is a mistake:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
return sendTicketMail(
to,
"KisAssistant — access request under review",
"Under review",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>An admin is now reviewing your request${reason ? `: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You can add arguments on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Forward each new ticket message to the requester (notification-only). */
export async function sendAccessMessageEmail(
to: string,
username: string,
fromAdmin: boolean,
body: string,
reviewUrl: string,
): Promise<boolean> {
const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body;
return sendTicketMail(
to,
`KisAssistant — new message on your access request`,
"New ticket message",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p><strong>${fromAdmin ? "An admin" : "You"}</strong> wrote on your access-request ticket:</p>` +
`<p style="background:#f5f5f4;border-left:4px solid #059669;border-radius:0 8px 8px 0;padding:12px 16px;white-space:pre-wrap;">${escHtml(excerpt)}</p>` +
`<p>Reply on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
if (!warned) {
warned = true;
console.error("[mail] SMTP_HOST not set — recovery emails disabled");
}
return false;
}
try {
await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — recover your access key",
text:
`Hello ${username},\n\n` +
`Someone requested a new access key for your KisAssistant account.\n` +
`Open this link within 60 minutes to get a fresh key (your old one stops working):\n\n` +
`${resetUrl}\n\n` +
`If you did not request this, just ignore this email.\n`,
});
console.log(`[mail] recovery email sent to ${username}`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
+132
View File
@@ -0,0 +1,132 @@
import { getDb, getSetting, setSetting } from "./db.ts";
/**
* Per-model access policy (admin kill-switch + per-user rate limits).
* Stored as one JSON blob in the `settings` table (`model_policy` key);
* usage counters live in `model_usage` (hourly + daily fixed windows).
*
* Policy is global, not per-user; admins always bypass checks.
* Limits of 0 mean "unlimited". Models without an entry use the default
* (enabled, no limits); entries equal to the default are pruned.
*/
export interface ModelPolicyEntry {
enabled: boolean;
/** Max requests per UTC hour (0 = unlimited). */
hourly: number;
/** Max requests per UTC day (0 = unlimited). */
daily: number;
}
export const DEFAULT_POLICY: ModelPolicyEntry = { enabled: true, hourly: 0, daily: 0 };
export type ModelPolicy = Record<string, ModelPolicyEntry>;
const SETTINGS_KEY = "model_policy";
export const MAX_LIMIT = 1_000_000;
function numOrZero(v: unknown): number {
return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= MAX_LIMIT ? v : 0;
}
function sanitizeEntry(v: unknown): ModelPolicyEntry {
const o = (v ?? {}) as Record<string, unknown>;
return {
enabled: typeof o.enabled === "boolean" ? o.enabled : true,
hourly: numOrZero(o.hourly),
daily: numOrZero(o.daily),
};
}
function isDefault(e: ModelPolicyEntry): boolean {
return e.enabled && e.hourly === 0 && e.daily === 0;
}
export function getModelPolicy(): ModelPolicy {
const raw = getSetting(SETTINGS_KEY, "");
if (!raw) return {};
try {
const obj = JSON.parse(raw) as Record<string, unknown>;
if (!obj || typeof obj !== "object") return {};
const out: ModelPolicy = {};
for (const [k, v] of Object.entries(obj)) {
if (k && k.length <= 200) out[k] = sanitizeEntry(v);
}
return out;
} catch {
return {};
}
}
export function policyFor(policy: ModelPolicy, modelId: string): ModelPolicyEntry {
return policy[modelId] ?? { ...DEFAULT_POLICY };
}
export function setModelPolicyEntry(
modelId: string,
patch: { enabled?: boolean; hourly?: number; daily?: number },
): ModelPolicyEntry {
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY");
if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY");
if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY");
const policy = getModelPolicy();
const cur = policy[modelId] ?? { ...DEFAULT_POLICY };
const next: ModelPolicyEntry = {
enabled: patch.enabled ?? cur.enabled,
hourly: patch.hourly ?? cur.hourly,
daily: patch.daily ?? cur.daily,
};
if (next.hourly < 0 || next.hourly > MAX_LIMIT || next.daily < 0 || next.daily > MAX_LIMIT) {
throw new Error("BAD_POLICY");
}
if (isDefault(next)) delete policy[modelId];
else policy[modelId] = next;
setSetting(SETTINGS_KEY, JSON.stringify(policy));
return { ...DEFAULT_POLICY, ...policy[modelId] };
}
export function initModelUsageTables(): void {
getDb().query(`CREATE TABLE IF NOT EXISTS model_usage (
user_id TEXT NOT NULL,
model TEXT NOT NULL,
window TEXT NOT NULL,
bucket INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, model, window, bucket)
)`).run();
}
function buckets(now = Date.now()): { h: number; d: number } {
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
}
function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): number {
const row = getDb().query(
"SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND window = ? AND bucket = ?",
).get(userId, modelId, window, bucket) as { n: number } | null;
return row?.n ?? 0;
}
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
export function checkModelAccess(userId: string, modelId: string): ModelAccess {
const p = policyFor(getModelPolicy(), modelId);
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
const { h, d } = buckets();
if (p.hourly > 0 && windowCount(userId, modelId, "h", h) >= p.hourly) {
return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" };
}
if (p.daily > 0 && windowCount(userId, modelId, "d", d) >= p.daily) {
return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" };
}
return { ok: true };
}
export function recordModelUse(userId: string, modelId: string): void {
const d = getDb();
const { h, d: day } = buckets();
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'h', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, h);
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'd', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, day);
d.query("DELETE FROM model_usage WHERE (window = 'h' AND bucket < ?) OR (window = 'd' AND bucket < ?)").run(h - 1, day - 1);
}
+180
View File
@@ -0,0 +1,180 @@
import { randomUUID } from "node:crypto";
import { getConversation, getDb, getMessages } from "./db.ts";
/**
* Content reports (user-flagged AI responses). Tables are created
* idempotently here so db.ts stays untouched — same pattern as access.ts.
*
* Flow: user hits "Report" under an assistant message, picks a reason
* (copyright / gore / falseinfo / bug) -> row in `reports` with a content
* snapshot (conversation may later be edited or deleted) -> admins triage
* in the AdminCenter (open / reviewing / resolved / dismissed + note).
*/
export const REPORT_REASONS = ["copyright", "gore", "falseinfo", "bug"] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"] as const;
export type ReportStatus = (typeof REPORT_STATUSES)[number];
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
/** Snapshot of the reported assistant message. */
content: string;
/** Snapshot of the user prompt that triggered it (previous user message). */
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
/** Live flag from users.report_shadowbanned (JOIN, not stored). */
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
export function initReportsTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
reporter_id TEXT NOT NULL,
reporter_name TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
message_index INTEGER NOT NULL DEFAULT -1,
content TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
admin_note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
// Prompt snapshot added after the initial schema — keep idempotent.
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
if (!rcols.some((c) => c.name === "prompt")) {
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
}
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
}
}
/** Max non-terminal reports per reporter (spam guard). */
export const MAX_OPEN_REPORTS = 20;
/** Still-triageable report already covering this message, if any. */
export function findOpenReport(conversationId: string, messageIndex: number): Report | null {
if (!conversationId || messageIndex < 0) return null;
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
).get(conversationId, messageIndex) as Report | null;
}
export function createReport(opts: {
reporterId: string;
reporterName: string;
conversationId: string;
messageIndex: number;
reason: ReportReason;
details: string;
clientContent: string;
clientPrompt: string;
clientModel: string;
}): Report {
const d = getDb();
const dup = opts.conversationId && opts.messageIndex >= 0
? findOpenReport(opts.conversationId, opts.messageIndex)
: null;
if (dup) {
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
err.report = dup;
throw err;
}
const open = d.query(
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
).get(opts.reporterId) as { n: number };
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
let content = opts.clientContent;
let prompt = opts.clientPrompt;
let model = opts.clientModel;
if (opts.conversationId) {
const conv = getConversation(opts.conversationId);
if (conv && conv.user_id === opts.reporterId) {
if (conv.model) model = conv.model;
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
if (opts.messageIndex >= 0) {
const at = msgs[opts.messageIndex];
if (at) content = at.content;
const prev = msgs[opts.messageIndex - 1];
if (prev && prev.role === "user") prompt = prev.content;
}
}
}
const now = Date.now();
const row: Report = {
id: randomUUID(),
reporter_id: opts.reporterId,
reporter_name: opts.reporterName,
conversation_id: opts.conversationId,
message_index: opts.messageIndex,
content,
prompt,
model,
reason: opts.reason,
details: opts.details,
status: "open",
admin_note: "",
reporter_shadowbanned: 0,
created_at: now,
updated_at: now,
};
d.query(
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
row.created_at, row.updated_at,
);
return row;
}
export function listReports(): Report[] {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
).all() as Report[];
}
export function getReport(id: string): Report | null {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
).get(id) as Report | null;
}
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
status, adminNote, Date.now(), id,
);
return getReport(id);
}
/**
* Shadow-ban a reporter: their reports are still accepted but flagged
* unreliable (excluded from the admin badge). Returns the new flag.
*/
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
return row?.v ?? 0;
}
+38
View File
@@ -0,0 +1,38 @@
import { join } from "node:path";
const PUBLIC_DIR = join(import.meta.dir, "..", "public");
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
};
export async function serveStatic(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("not found", { status: 404 });
}
let path = decodeURIComponent(url.pathname);
if (path === "/") path = "/index.html";
// block traversal
if (path.includes("..") || path.includes("\0")) return new Response("not found", { status: 404 });
const file = Bun.file(join(PUBLIC_DIR, path));
if (!(await file.exists())) {
// SPA fallback for non-API routes
if (!path.startsWith("/api/")) {
return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
}
return new Response("not found", { status: 404 });
}
const ext = path.slice(path.lastIndexOf("."));
return new Response(file, {
headers: {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": path === "/index.html" ? "no-cache" : "public, max-age=3600",
},
});
}
+121
View File
@@ -0,0 +1,121 @@
import { config } from "../config.ts";
import { isOcrToolEnabled } from "../db.ts";
import { driverLog, fmtMs } from "../drivers/log.ts";
import type { PlatformTool } from "./types.ts";
/**
* OCR tool: transcribes JPG/PNG/WEBP images to TXT on the backend
* (tesseract binary) so the model only ever receives text.
* The frontend shows the transcription for user review before sending.
*/
const OCR_MAX_BYTES = 5 * 1024 * 1024;
// CPU protection: max OCR jobs per account per hour.
const OCR_WINDOW_MS = 3600_000;
const OCR_MAX_JOBS = 20;
const ocrJobs = new Map<string, number[]>();
export function ocrAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
const now = Date.now();
const list = (ocrJobs.get(userId) ?? []).filter((t) => t > now - OCR_WINDOW_MS);
ocrJobs.set(userId, list);
if (list.length >= OCR_MAX_JOBS) {
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + OCR_WINDOW_MS - now) / 1000)) };
}
return { ok: true, retryAfterSec: 0 };
}
export function recordOcrJob(userId: string): void {
const list = ocrJobs.get(userId) ?? [];
list.push(Date.now());
ocrJobs.set(userId, list);
}
function tessBin(): string {
return process.env["TESSERACT_BIN"] || "tesseract";
}
let availableCache: boolean | null = null;
async function checkBinary(): Promise<boolean> {
try {
const proc = Bun.spawn([tessBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
const code = await proc.exited;
return code === 0;
} catch {
return false;
}
}
export class OcrTool implements PlatformTool {
readonly name = "ocr";
readonly description = "Transcribe image (jpg/png/webp) to text on the server (tesseract) for user review before sending to the model.";
private available: boolean | null = null;
async init(): Promise<void> {
if (availableCache === null) {
availableCache = await checkBinary();
driverLog("tools", `ocr engine ${tessBin()}: ${availableCache ? "available" : "MISSING — image transcription disabled"}`);
}
this.available = availableCache;
}
isAvailable(): boolean {
return (this.available ?? false) && isOcrToolEnabled();
}
unavailableReason(): string | null {
if (!(this.available ?? false)) {
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
}
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features).";
return null;
}
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
try {
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
stdin: image,
stdout: "pipe",
stderr: "pipe",
});
} catch (e) {
throw new Error(`ocr engine failed to start: ${(e as Error).message}`);
}
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
// already exited
}
}, 120_000);
try {
const [code, out, errText] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if (code !== 0) {
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
}
let text = out.replace(/\f/g, "").trim();
let truncated = false;
if (text.length > maxChars) {
text = text.slice(0, maxChars);
truncated = true;
}
driverLog("tools", `ocr done: ${text.length} chars${truncated ? " (truncated)" : ""} in ${fmtMs(Date.now() - t0)}`);
return { text, truncated };
} finally {
clearTimeout(timeout);
}
}
}
export { OCR_MAX_BYTES };
+23
View File
@@ -0,0 +1,23 @@
import { OcrTool } from "./ocr.ts";
import type { PlatformTool } from "./types.ts";
/** Platform tools registry (upload flows always go through tools). */
class ToolRegistry {
readonly ocr = new OcrTool();
private tools: PlatformTool[] = [this.ocr];
async init(): Promise<void> {
await this.ocr.init();
}
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
return this.tools.map((t) => ({
name: t.name,
description: t.description,
available: t.isAvailable(),
reason: t.unavailableReason(),
}));
}
}
export const tools = new ToolRegistry();
+8
View File
@@ -0,0 +1,8 @@
/** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */
export interface PlatformTool {
readonly name: string;
readonly description: string;
isAvailable(): boolean;
unavailableReason(): string | null;
}
+139
View File
@@ -0,0 +1,139 @@
import { createHash, createHmac, randomBytes } from "node:crypto";
import { getDb } from "./db.ts";
/**
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
* The per-user secret lives in `users.totp_secret` (empty = disabled);
* login challenges live in `totp_challenges`. Tables/columns are created
* idempotently here so db.ts stays untouched.
*/
const STEP_SEC = 30;
const DIGITS = 6;
const WINDOW = 1;
const CHALLENGE_TTL_MS = 5 * 60_000;
export function initTotpTables(): void {
const d = getDb();
try {
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
} catch {
// column already exists — fine
}
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`).run();
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function newTotpSecret(bytes = 20): string {
const buf = randomBytes(bytes);
let out = "";
let bits = 0;
let value = 0;
for (const b of buf) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
function base32Decode(s: string): Buffer {
const clean = s.trim().replace(/=+$/, "").toUpperCase();
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error("invalid base32");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
function hotp(key: Buffer, counter: bigint): string {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(counter);
const h = createHmac("sha1", key).update(msg).digest();
const offset = h[h.length - 1]! & 0x0f;
const code =
((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!));
return String(code % 10 ** DIGITS).padStart(DIGITS, "0");
}
/** Timing-safe comparison against the current ±1 window. */
export function verifyTotp(secret: string, code: string): boolean {
const c = code.trim();
if (!/^\d{6}$/.test(c)) return false;
let key: Buffer;
try {
key = base32Decode(secret);
} catch {
return false;
}
if (key.length < 10) return false;
const now = Math.floor(Date.now() / 1000 / STEP_SEC);
let ok = false;
for (let w = -WINDOW; w <= WINDOW; w++) {
const expected = hotp(key, BigInt(now + w));
const a = Buffer.from(expected);
const b = Buffer.from(c);
if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) {
ok = true;
}
}
// Constant-time-ish: always hash-compare all window slots (loop above).
return ok;
}
export function getTotpSecret(userId: string): string {
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
return row?.s ?? "";
}
export function setTotpSecret(userId: string, secret: string): void {
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
}
/** Short-lived login challenge after a correct key; returns the raw token. */
export function createTotpChallenge(userId: string): string {
const token = "tc_" + randomBytes(24).toString("base64url");
const hash = createHash("sha256").update(token).digest("hex");
const now = Date.now();
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
hash, userId, now, now + CHALLENGE_TTL_MS,
);
// Opportunistic cleanup.
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
return token;
}
export function consumeTotpChallenge(token: string): string | null {
const hash = createHash("sha256").update(token).digest("hex");
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
user_id: string;
expires_at: number;
} | null;
if (!row) return null;
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
if (row.expires_at < Date.now()) return null;
return row.user_id;
}
+119
View File
@@ -0,0 +1,119 @@
import { getDb } from "./db.ts";
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts";
import type { DriverModel, LLMDriver } from "./drivers/types.ts";
/**
* Personal LLM providers (BYOK): users attach their own API keys in
* settings, and the matching models show up in their model menu.
*
* Security notes (do not weaken):
* - Keys are stored reversibly in SQLite (they must be sent upstream) and
* are NEVER returned to any client — GET only exposes presence + last4.
* - Never log or print keys. Key material only travels server -> provider.
* - Scoping is per account: every helper takes userId and all chat/model
* routes resolve keys for the caller only.
*/
// Providers a user may connect themselves. Keep in sync with the settings
// UI (frontend PROVIDER_META) and the docs.
export const USER_PROVIDER_IDS = ["openai", "anthropic", "deepseek", "gemini"] as const;
export type UserProviderId = (typeof USER_PROVIDER_IDS)[number];
export const USER_PROVIDER_LABELS: Record<UserProviderId, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
deepseek: "DeepSeek",
gemini: "Gemini",
};
export function isUserProviderId(v: string): v is UserProviderId {
return (USER_PROVIDER_IDS as readonly string[]).includes(v);
}
/** User-supplied key validation: trimmed printable secret, 8–256 chars. */
export function validProviderKey(v: unknown): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length < 8 || t.length > 256) return null;
if (/[\s]/.test(t)) return null;
return t;
}
export interface UserProviderStatus {
provider: UserProviderId;
label: string;
hasKey: boolean;
/** Last 4 chars of the stored key (for recognition), or null when unset. */
last4: string | null;
updatedAt: number;
}
export function getUserProviderKey(userId: string, provider: UserProviderId): string | null {
const r = getDb()
.query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?")
.get(userId, provider) as { api_key: string } | null;
return r?.api_key ?? null;
}
export function listUserProviders(userId: string): UserProviderStatus[] {
const rows = getDb()
.query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?")
.all(userId) as { provider: string; api_key: string; updated_at: number }[];
const byId = new Map(rows.map((r) => [r.provider, r]));
return USER_PROVIDER_IDS.map((p) => {
const r = byId.get(p);
return {
provider: p,
label: USER_PROVIDER_LABELS[p],
hasKey: !!r,
last4: r ? r.api_key.slice(-4) : null,
updatedAt: r?.updated_at ?? 0,
};
});
}
export function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): void {
getDb()
.query(
"INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at",
)
.run(userId, provider, apiKey, Date.now());
}
export function deleteUserProviderKey(userId: string, provider: UserProviderId): void {
getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider);
}
/** Build a per-user driver instance authenticated with the user's own key. */
export function buildUserProviderDriver(provider: UserProviderId, apiKey: string): LLMDriver {
switch (provider) {
case "openai":
return new OpenAIDriver(apiKey);
case "anthropic":
return new AnthropicDriver(apiKey);
case "deepseek":
return new DeepSeekDriver(apiKey);
case "gemini":
return new GeminiDriver(apiKey);
}
}
/**
* List models reachable with the user's own keys. Never throws: a failing
* provider is skipped (its driver falls back to a static list, and only a
* total failure is swallowed here).
*/
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
const out: DriverModel[] = [];
const jobs = USER_PROVIDER_IDS.map(async (p) => {
const key = getUserProviderKey(userId, p);
if (!key) return;
try {
out.push(...(await buildUserProviderDriver(p, key).listModels()));
} catch (e) {
console.error(`[providers] user ${p} listModels failed:`, (e as Error).message);
}
});
await Promise.all(jobs);
return out;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"types": ["bun"]
},
"include": ["src", "scripts"]
}