Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b010762a0b | ||
|
|
2dab720e55 | ||
|
|
67f13dbc93 | ||
|
|
a7f0097a11 | ||
|
|
644aa21426 | ||
|
|
6ebd26b60e | ||
|
|
ac04c674a6 | ||
|
|
30918f33e7 | ||
|
|
8d72defb82 | ||
|
|
562ab5191f | ||
|
|
796a883d70 | ||
|
|
6e65d84a1c | ||
|
|
8e64c0c963 | ||
|
|
4005ce0631 | ||
|
|
cf4480743e | ||
|
|
3a34790487 | ||
|
|
2402e2e7fd | ||
|
|
a57f5f0951 | ||
|
|
c9e53193b9 | ||
|
|
e9695c0239 | ||
|
|
b5d17d8944 | ||
|
|
eff63e0024 | ||
|
|
76f1834712 | ||
|
|
2592c92f66 | ||
|
|
aad8e2600f | ||
|
|
7defbfcc2e | ||
|
|
22ffb31a66 | ||
|
|
0ee5a3521a | ||
|
|
0810ad4166 | ||
|
|
2974105e25 | ||
|
|
aaf9354b7f | ||
|
|
9dd8e772a9 | ||
|
|
ec0191f84a | ||
|
|
360f203ca2 | ||
|
|
c639f43891 | ||
|
|
3571c99416 | ||
|
|
0ca3e9d4ca | ||
|
|
0943cdf5f3 | ||
|
|
19b791493f | ||
|
|
b4cb62b490 | ||
|
|
59fc78332d | ||
|
|
5c616a9d03 | ||
|
|
a31fc02ef6 | ||
|
|
836995824a | ||
|
|
43003957eb | ||
|
|
e69654430a | ||
|
|
aa33828215 | ||
|
|
4cefd3e17f | ||
|
|
c0e7899ab3 | ||
|
|
62370e61bf | ||
|
|
5903b77d95 | ||
|
|
2a34d16b52 | ||
|
|
670d7f1dd1 | ||
|
|
6d2d9b897e | ||
|
|
2705dd47b3 | ||
|
|
acdfe4ca8e | ||
|
|
908c673c58 | ||
|
|
2e6eb07301 | ||
|
|
66450303a1 | ||
|
|
af9b8c17d0 | ||
|
|
245d26677b | ||
|
|
70c21698e8 | ||
|
|
8038e02303 | ||
|
|
ad1629164f | ||
|
|
8f49bb56a2 | ||
|
|
efeb11577d | ||
|
|
48fb137c13 | ||
|
|
b357d89d81 | ||
|
|
f46a6b4412 | ||
|
|
1352f5374e | ||
|
|
d3780edc9c | ||
|
|
133a08b7ed | ||
|
|
2dfcb944d1 | ||
|
|
47f78133c4 | ||
|
|
8c8a89ab30 | ||
|
|
a9578438b0 | ||
|
|
3a65f1ba69 | ||
|
|
19a47372c6 | ||
|
|
9c3994241a | ||
|
|
8cb307c9a1 | ||
|
|
20fab17880 | ||
|
|
a196e25e83 | ||
|
|
bec38d9f34 | ||
|
|
1de54b3caf | ||
|
|
c18397880a | ||
|
|
5f5f72268a | ||
|
|
c05aa7a211 | ||
|
|
59402b250c | ||
|
|
6cb9cba384 | ||
|
|
a5da592fcf | ||
|
|
330fffc700 | ||
|
|
06447062be | ||
|
|
0e44bf6364 | ||
|
|
db14c9bf21 | ||
|
|
d05dad2e48 | ||
|
|
56628fa103 | ||
|
|
d37f4efd69 | ||
|
|
739c14324a |
@@ -0,0 +1,92 @@
|
|||||||
|
# --- 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
|
||||||
|
# Postgres fallback (typically a docker container). Empty = local SQLite.
|
||||||
|
# Example: POSTGRESQL_URL=postgres://kisassistant:secret@localhost:5432/kisassistant
|
||||||
|
POSTGRESQL_URL=
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# --- platform tools: neural TTS (first configured wins, TTS_PROVIDER forces one) ---
|
||||||
|
# ElevenLabs (most natural): https://elevenlabs.io/app/settings/api-keys
|
||||||
|
#ELEVENLABS_API_KEY=
|
||||||
|
#ELEVENLABS_VOICE=
|
||||||
|
# OpenAI (reuses the LLM key when set, tts-1 + alloy/echo/fable/onyx/nova/shimmer)
|
||||||
|
#OPENAI_API_KEY=
|
||||||
|
#TTS_VOICE=alloy
|
||||||
|
# Local piper (offline, like tesseract): PIPER_BIN + onnx model
|
||||||
|
#PIPER_BIN=piper
|
||||||
|
#PIPER_MODEL=/var/lib/kisassistant/piper/en_US-lessac-medium.onnx
|
||||||
|
# Test/development overrides (base URLs, default = api.elevenlabs.io / api.openai.com)
|
||||||
|
#ELEVENLABS_BASE_URL=
|
||||||
|
#OPENAI_BASE_URL=
|
||||||
|
#TTS_PROVIDER=auto
|
||||||
|
|
||||||
|
# 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
@@ -0,0 +1,11 @@
|
|||||||
|
/data/
|
||||||
|
/cdn/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
/public/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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 by default via Bun's native `bun:sqlite` driver
|
||||||
|
(Postgres fallback via native `bun:sql` when `POSTGRESQL_URL` is set).
|
||||||
|
|
||||||
|
## 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: async store fns through the `DbClient` interface (`src/db/client.ts`) — SQLite default (`bun:sqlite`, `PRAGMA journal_mode=WAL`), Postgres when `POSTGRESQL_URL` is set (Bun native `bun:sql`). Write queries once with `?` placeholders (auto-rewritten to `$n` for pg), keep SQL portable (no bare `GROUP BY pk` — pg rejects it; quote `"window"`). 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.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 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/Postgres** database (Bun native drivers) 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 |
|
||||||
|
| POST | `/api/admin/users/:id/reset-link` | admin | email a one-time password-reset link (needs user email + SMTP) |
|
||||||
|
| 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 (library tag or `hf.co/…`, `{name, nodeId?}`, NDJSON progress) |
|
||||||
|
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model (`?node=` for a pool node) |
|
||||||
|
| GET/POST | `/api/admin/ollama/nodes` | admin | Ollama pool: list (+effective order) / register a host |
|
||||||
|
| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node |
|
||||||
|
| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d |
|
||||||
|
| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it |
|
||||||
|
| GET | `/api/admin/platform` | admin | this host facts: hostname, kernel, uptime, load, memory, IPs |
|
||||||
|
| GET | `/api/tools/tts/info` | Bearer | neural TTS provider + voices (null = browser fallback) |
|
||||||
|
| POST | `/api/tools/tts` | Bearer | synthesize `{text, voice?}` → audio bytes (cached, 30/hour) |
|
||||||
|
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
||||||
|
| 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 + report events (badges + triage lists, full payloads, no polling) |
|
||||||
|
| 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 default, Postgres fallback — both Bun native)
|
||||||
|
|
||||||
|
`$DATA_DIR/kisassistant.db` (WAL mode), or Postgres when `POSTGRESQL_URL` is
|
||||||
|
set (e.g. a docker container — see `docs/OPERATIONS.md`). 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 async store (users, sessions, convs, messages, shares) — SQLite/Postgres via DbClient
|
||||||
|
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, per-conversation
|
||||||
|
URLs (`/chat/:id`, deep-linkable). English only.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Architecture (backend)
|
||||||
|
|
||||||
|
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
|
||||||
|
frontend (same origin, no CORS). Persistence is SQLite by default via Bun's
|
||||||
|
native `bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode), or
|
||||||
|
Postgres when `POSTGRESQL_URL` is set (Bun's native `bun:sql` driver,
|
||||||
|
typically a docker container — see `docs/OPERATIONS.md`).
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──► Bun.serve (src/index.ts)
|
||||||
|
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► sqlite file | postgres
|
||||||
|
│ └─────► DriverRegistry ──► ollama / APIs
|
||||||
|
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id and /chat/:id)
|
||||||
|
```
|
||||||
|
|
||||||
|
All store functions (`db.ts`, `access.ts`, `reports.ts`, `totp.ts`,
|
||||||
|
`modelPolicy.ts`, `userProviders.ts`) are `async` and talk through the
|
||||||
|
`DbClient` interface (`src/db/client.ts`): `get` / `all` / `run` / `exec` /
|
||||||
|
`insertReturningId` / `transaction`. Queries are written once with `?`
|
||||||
|
placeholders and translated to `$1, $2, …` for Postgres (quote-aware);
|
||||||
|
Postgres `BIGINT` cells arrive as `bigint` and are normalized to `number`.
|
||||||
|
One portable-SQL rule this forces: no bare `GROUP BY pk` with unaggregated
|
||||||
|
columns (rejected by Postgres) — use correlated subqueries instead.
|
||||||
|
|
||||||
|
## 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 `initDb()` (no migration framework; keep it
|
||||||
|
so). On Postgres, timestamps are `BIGINT`, auto ids `BIGSERIAL`, later
|
||||||
|
columns arrive via `ADD COLUMN IF NOT EXISTS`, and `"window"` (model_usage)
|
||||||
|
is quoted — `WINDOW` is reserved on Postgres.
|
||||||
|
|
||||||
|
| 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.
|
||||||
|
`sanitizeOcrText` strips engine noise (control/format/private-use code
|
||||||
|
points, U+FFFD markers, form-feeds) while keeping legitimate text
|
||||||
|
(accents, CJK, emoji). 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. Multi-node pool (`src/ollamaNodes.ts`:
|
||||||
|
`ollama_nodes` table + `effectiveOllamaNodes()`): each generation picks a
|
||||||
|
node by weighted random and fails over pre-stream on error/timeout
|
||||||
|
(`ollama_timeout_s` setting); per-attempt telemetry (ok, latency, token
|
||||||
|
counts) lands in `ollama_stats` for the admin charts.
|
||||||
|
- `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 (a personal entry
|
||||||
|
replaces the platform entry with the same id, flagged `personal: true` so
|
||||||
|
the picker lists it under its own "driver · personal key" section), 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).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 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).
|
||||||
|
Bare `/chat` is the empty composer (new chat, created lazily on first send).
|
||||||
|
- `/chat/:id` — one conversation page (deep-linkable, back/forward supported).
|
||||||
|
Selecting a chat navigates there; unknown ids bounce back to `/chat`.
|
||||||
|
Archived chats are viewed read-only on bare `/chat` (no per-id URL).
|
||||||
|
- `/` — 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 is replaced by an unarchive banner with a share button
|
||||||
|
(archived chats stay shareable, but an archived public chat cannot go
|
||||||
|
private again — `DELETE …/share` answers 403 until unarchived).
|
||||||
|
- `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 / share 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, a
|
||||||
|
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||||
|
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||||
|
- Assistant bubbles show the producing model (`<model>` chip in the footer,
|
||||||
|
stored per message in `messages.model` — survives model switches; same
|
||||||
|
chip on the streaming bubble and shared pages).
|
||||||
|
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||||
|
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||||
|
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||||
|
status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx`
|
||||||
|
(live Hugging Face GGUF search + one-click `hf.co/…` pull to a node).
|
||||||
|
Stats tab: `StatsSection.tsx` (node/window pickers, bar/area/line dock, SVG
|
||||||
|
tooltips, platform host cards).
|
||||||
|
- Accent color (`lib/accent.ts`) derives the 50–950 ramp from one hex and
|
||||||
|
also repaints the SVG favicon (data URL) so the tab icon matches the dress.
|
||||||
|
- Voice (`lib/voice.ts`, no dependency): `Chat.tsx` composer mic with live
|
||||||
|
waveform + speech-recognition transcript (editable before send), per-bubble
|
||||||
|
playback with glow through a unified player (`playText`: neural server
|
||||||
|
audio via `POST /api/tools/tts` with browser-synthesis fallback),
|
||||||
|
`Settings → Voice` prefs (engine/lang/voice/speed/pitch/neural voice,
|
||||||
|
localStorage), admin-editable preview lines.
|
||||||
|
|
||||||
|
## 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; `sunset` follows the browser's local time —
|
||||||
|
light 07:00–19:00, dark otherwise — re-applied at each boundary via
|
||||||
|
`lib/solarTheme.ts`). 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.
|
||||||
@@ -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) |
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# 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 — unused when `POSTGRESQL_URL` is set) |
|
||||||
|
| `POSTGRESQL_URL` | *(empty)* | Postgres fallback, e.g. `postgres://kisassistant:secret@localhost:5432/kisassistant` (typically a docker container). Empty = local SQLite. Boot fails fast when unreachable. |
|
||||||
|
| `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`.
|
||||||
|
|
||||||
|
## Postgres fallback (docker)
|
||||||
|
|
||||||
|
SQLite is the default. For Postgres, run a container and point the app at it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name kisassistant-pg --restart unless-stopped \
|
||||||
|
-e POSTGRES_USER=kisassistant -e POSTGRES_PASSWORD=secret \
|
||||||
|
-e POSTGRES_DB=kisassistant -p 127.0.0.1:5432:5432 \
|
||||||
|
-v kisassistant-pgdata:/var/lib/postgresql/data \
|
||||||
|
postgres:17-alpine
|
||||||
|
|
||||||
|
POSTGRESQL_URL=postgres://kisassistant:[email protected]:5432/kisassistant \
|
||||||
|
DATA_DIR=/tmp/ka-pg-throwaway \
|
||||||
|
bun run src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The app uses Bun's native `bun:sql` driver — no extra dependency.
|
||||||
|
- Schema is created idempotently at boot (same tables as SQLite; timestamps
|
||||||
|
are `BIGINT`, auto ids `BIGSERIAL`). A fresh database starts empty except
|
||||||
|
the bootstrapped `admin` row — **there is no SQLite→Postgres data
|
||||||
|
migration** (re-create accounts or keep SQLite).
|
||||||
|
- Switching back is just unsetting `POSTGRESQL_URL` (the SQLite file is
|
||||||
|
untouched while Postgres is active).
|
||||||
|
- Back up with `pg_dump` (SQLite: copy `kisassistant.db`, or
|
||||||
|
`.backup` while running under WAL).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
seven deep-linkable tabs (`/admin` = accounts, `/admin/models`,
|
||||||
|
`/admin/stats`, `/admin/access`, `/admin/reports`, `/admin/features`,
|
||||||
|
`/admin/mail`): **Accounts** (same manager as before), **Access**
|
||||||
|
(access-request wishlist triage, see below), **Reports**, **Models** (node
|
||||||
|
pool, per-model policy, Ollama library + HF catalog), **Stats** (pool load
|
||||||
|
charts + platform host facts, see below), **Features** (server-side
|
||||||
|
switches + voice-preview lines), **Mail** (SMTP credentials viewer +
|
||||||
|
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
||||||
|
`POST /api/admin/mail/test`, admin Bearer only — plus the password-recovery
|
||||||
|
rate limits below).
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `reports_enabled` (default on): admin kill-switch for message reports —
|
||||||
|
the chat flag button turns grey with a "Report disabled" tooltip and
|
||||||
|
`POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`.
|
||||||
|
- `username_change_enabled` (default on): lets users rename themselves
|
||||||
|
(`PATCH /api/me` `username`); off answers 403 and locks the Settings field.
|
||||||
|
Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to
|
||||||
|
clients via `GET /api/tools`.
|
||||||
|
- Flags live in the `settings` SQLite table, edited via
|
||||||
|
`GET/PATCH /api/admin/settings` (admin Bearer only). The same endpoint
|
||||||
|
carries the Ollama pool tuning (`ollama_timeout_s` 5–3600, default 300;
|
||||||
|
`ollama_default_weight` 0–1000, default 100) and the recovery envelope
|
||||||
|
(`recovery_limit_max` 1–100, default 5; `recovery_limit_window_min`
|
||||||
|
1–1440, default 60).
|
||||||
|
|
||||||
|
## Password recovery rate limits
|
||||||
|
|
||||||
|
`POST /api/auth/recover` and the `/api/auth/reset/:token` endpoints share a
|
||||||
|
dedicated per-IP envelope (separate from login brute-force protection):
|
||||||
|
every hit counts, successes included (recovery answers are always-ok by
|
||||||
|
design, so enumeration is impossible). Over-limit answers 429 with
|
||||||
|
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
||||||
|
Admin Center → Mail → Password recovery limits, no restart. Admins can also
|
||||||
|
email a one-time reset link to any user with an address from the Accounts
|
||||||
|
tab (`POST /api/admin/users/:id/reset-link` — 409 without email, 501
|
||||||
|
without SMTP).
|
||||||
|
|
||||||
|
## 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?}`).
|
||||||
|
Ollama library: `POST /api/admin/ollama/pull` (`{name, nodeId?}`, NDJSON
|
||||||
|
progress — library tags AND `hf.co/user/repo:quant` references),
|
||||||
|
`DELETE /api/admin/ollama/models/:name[?node=]`, and the connectivity probe
|
||||||
|
`GET /api/admin/ollama/status` (default node — surfaced in the node pool).
|
||||||
|
|
||||||
|
## Ollama node pool (multi-host, weighted)
|
||||||
|
|
||||||
|
Admin Center → Models → **Ollama nodes**: register extra Ollama hosts
|
||||||
|
(modal with name, host, weight + a Test probe before saving). The
|
||||||
|
env-configured `OLLAMA_HOST` stays as the implicit `default` node (read-only
|
||||||
|
row, `env` badge). Each generation picks a node by **weighted random**
|
||||||
|
among enabled nodes with weight > 0 (share shown as % — firewall-like
|
||||||
|
priority, heaviest first) and fails over to the next node on error or
|
||||||
|
timeout, but only before any token was produced (a started reply never
|
||||||
|
restarts elsewhere). Weight 0 or disabled = no traffic. Pool changes
|
||||||
|
invalidate the model list cache; `listModels` merges tags across nodes.
|
||||||
|
|
||||||
|
- `ollama_timeout_s` (default 300 s, Admin Center → Models): max time for
|
||||||
|
one generation per node attempt (caller aborts propagate instantly and
|
||||||
|
never fail over).
|
||||||
|
- Node detail view: live probe (version, latency, on-disk models, running
|
||||||
|
models with VRAM via `/api/ps`) + load charts over 1h / 6h / 24h / 7d /
|
||||||
|
30d (requests, errors, avg latency, tokens/s from app-side generation
|
||||||
|
telemetry in `ollama_stats`, 35-day retention — Ollama exposes no CPU/RAM
|
||||||
|
API, so host CPU/RAM meters are intentionally absent).
|
||||||
|
- HF catalog (same tab): live Hugging Face search (`filter=gguf`, sorted by
|
||||||
|
downloads), per-repo quantization picker, one-click pull to the selected
|
||||||
|
node. Needs Ollama with `hf.co/…` support + internet on that host.
|
||||||
|
- Routes: `GET/POST /api/admin/ollama/nodes`, `PATCH/DELETE
|
||||||
|
/api/admin/ollama/nodes/:id`, `GET …/nodes/:id/status`,
|
||||||
|
`GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`,
|
||||||
|
`GET /api/admin/ollama/probe?host=…`.
|
||||||
|
|
||||||
|
## Telemetry sub-page (`/admin/stats`) + platform facts
|
||||||
|
|
||||||
|
The **Stats** tab is the pool observatory: node picker, window picker
|
||||||
|
(1h / 6h / 24h / 7d / 30d), chart-type dock (bar / area / line SVG charts
|
||||||
|
with hover tooltips: requests, errors, avg latency, tokens), totals tiles,
|
||||||
|
and a latency underlay. Below, **platform host facts** for this server —
|
||||||
|
hostname, kernel, arch/CPU, app + system uptime, load average, memory bar,
|
||||||
|
runtime, network interfaces/IPs (`GET /api/admin/platform`, admin only).
|
||||||
|
Disk meters are intentionally absent (models live on the Ollama hosts).
|
||||||
|
|
||||||
|
## Voice: dictation + playback
|
||||||
|
|
||||||
|
- **Composer mic** (browser speech recognition — Chrome/Edge): records with
|
||||||
|
a live gradient waveform, timer and live transcript; **Stop** drops the
|
||||||
|
transcript into the composer, editable before sending; **Discard** throws
|
||||||
|
it away. Unsupported browsers get a disabled mic with an explanatory
|
||||||
|
tooltip. No audio leaves the browser (no server STT endpoint yet —
|
||||||
|
whisper.cpp via `WHISPER_BIN` is the documented next step).
|
||||||
|
- **Message playback**: every assistant bubble has a Listen button with a
|
||||||
|
Siri-style breathing glow while speaking (click again to stop; playback
|
||||||
|
stops on send / new message / conversation switch). Engine per user
|
||||||
|
(Settings → Voice): **auto** (neural server voice when available, browser
|
||||||
|
fallback), **browser**, or **server**.
|
||||||
|
- **Neural server TTS** (`POST /api/tools/tts` `{text, voice?}` → audio
|
||||||
|
bytes, `GET /api/tools/tts/info` → provider + voices): ElevenLabs
|
||||||
|
(any voice_id, most natural) → OpenAI `tts-1` (6 stock voices, reuses the
|
||||||
|
LLM key) → local piper (`PIPER_BIN` + `PIPER_MODEL`, offline WAV).
|
||||||
|
`TTS_PROVIDER` forces one; `TTS_VOICE` / `ELEVENLABS_VOICE` set defaults.
|
||||||
|
Output is cached on disk (sha of provider|voice|text, 200 files cap),
|
||||||
|
rate-limited to 30 jobs/hour/account (429), kill-switched via
|
||||||
|
`tools_tts_enabled` (Admin Center → Features). Preview lines are
|
||||||
|
pre-generated once at boot and right after each preview save, so serving
|
||||||
|
users always hits the cache (`X-TTS-Cache: HIT/MISS` proves it). Test
|
||||||
|
overrides: `ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`.
|
||||||
|
- **Settings → Voice** (per browser, localStorage): engine, language
|
||||||
|
(auto-detect per message or forced), browser voice/rate/pitch, neural
|
||||||
|
voice id, and one-tap previews of the admin-set lines.
|
||||||
|
- **Admin Center → Features → Voice previews**: the default playback lines
|
||||||
|
(`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre,
|
||||||
|
voice}`, each with its OWN voice character — masculine/feminine/any
|
||||||
|
timbre hint, speed, pitch, optional server voice id picked from a select
|
||||||
|
menu fed by the provider catalog), also served to users via
|
||||||
|
`GET /api/tools`. Users can adopt any preview's character as their own
|
||||||
|
playback voice from Settings → Voice.
|
||||||
|
|
||||||
|
## 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 plus `report_created` + `report_status`,
|
||||||
|
driving the AdminCenter badges and the triage lists). Every event carries
|
||||||
|
its full payload (`access_created` bundles the ticket, its first message
|
||||||
|
and the message count), so clients apply it straight to state — HTTP
|
||||||
|
resync only happens on (re)connect to cover the offline gap. Clients ping
|
||||||
|
every 25 s (`{"type":"ping"}` → `{"type":"pong"}`) with a pong watchdog
|
||||||
|
that redials half-dead sockets.
|
||||||
|
|
||||||
|
## 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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, maximum-scale=1.0, user-scalable=no, 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>
|
||||||
Generated
+3833
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ka-petal" x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#047857"/>
|
||||||
|
<stop offset="0.6" stop-color="#10b981"/>
|
||||||
|
<stop offset="1" stop-color="#6ee7b7"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ka-petals" x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#10b981"/>
|
||||||
|
<stop offset="1" stop-color="#a7f3d0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="ka-core">
|
||||||
|
<stop offset="0" stop-color="#fef3c7"/>
|
||||||
|
<stop offset="0.5" stop-color="#fbbf24"/>
|
||||||
|
<stop offset="1" stop-color="#d97706"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(8 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="19" fill="url(#ka-petal)" transform="rotate(80 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20.5" fill="url(#ka-petal)" transform="rotate(151 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="19.5" fill="url(#ka-petal)" transform="rotate(223 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="27" rx="13.5" ry="20" fill="url(#ka-petal)" transform="rotate(294 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(44 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(116 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(188 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="11.5" fill="url(#ka-petals)" transform="rotate(259 50 50)"/>
|
||||||
|
<ellipse cx="50" cy="34" rx="8" ry="12" fill="url(#ka-petals)" transform="rotate(331 50 50)"/>
|
||||||
|
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22"/>
|
||||||
|
<circle cx="50" cy="50" r="8" fill="url(#ka-core)"/>
|
||||||
|
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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: /
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
import { useCallback, useEffect, useRef, 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 { adminTabFromPath, chatIdFromPath, isChatPath, 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 { Toasts } from "./components/Toasts.tsx";
|
||||||
|
import { pushToast } from "./lib/toasts.ts";
|
||||||
|
import { clearFailedEntry, getFailedEntry, getSavedModel, pickModel, setFailedEntry, setSavedModel } from "./lib/localPrefs.ts";
|
||||||
|
import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts";
|
||||||
|
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) || adminTabFromPath(window.location.pathname) !== null);
|
||||||
|
|
||||||
|
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 = resolveThemeDark(theme);
|
||||||
|
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);
|
||||||
|
// Async callbacks (refreshConvs) need the current selection without
|
||||||
|
// re-creating on every render.
|
||||||
|
const activeIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
activeIdRef.current = activeId;
|
||||||
|
}, [activeId]);
|
||||||
|
// Id of the conversation currently receiving a streamed reply (null when
|
||||||
|
// idle). Set synchronously before any state update in send()/retryFailed
|
||||||
|
// so the route/message effects below never clobber a conversation that is
|
||||||
|
// being created or written to: the URL stays bare /chat until we navigate
|
||||||
|
// explicitly, and the server message list is still empty while streaming.
|
||||||
|
const sendingForRef = useRef<string | null>(null);
|
||||||
|
// True once the conversation list was fetched at least once (so an
|
||||||
|
// unknown /chat/<id> can be bounced instead of flashing empty).
|
||||||
|
const [convsReady, setConvsReady] = useState(false);
|
||||||
|
// 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);
|
||||||
|
const [reportsEnabled, setReportsEnabled] = useState(true);
|
||||||
|
const [usernameChangeEnabled, setUsernameChangeEnabled] = useState(true);
|
||||||
|
// 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);
|
||||||
|
// Conversation currently receiving a streamed reply (null when idle).
|
||||||
|
// Streaming / thinking / sending UI is rendered ONLY there — every other
|
||||||
|
// conversation stays clean and selectable while a reply lands.
|
||||||
|
const [sendingFor, setSendingFor] = useState<string | null>(null);
|
||||||
|
function setFlight(id: string | null): void {
|
||||||
|
sendingForRef.current = id;
|
||||||
|
setSendingFor(id);
|
||||||
|
}
|
||||||
|
const { t } = useT();
|
||||||
|
|
||||||
|
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||||
|
if (IS_ADMIN_PAGE) {
|
||||||
|
return <AdminCenter initialTab={adminTabFromPath(window.location.pathname)} />;
|
||||||
|
}
|
||||||
|
if (RESET_TOKEN) {
|
||||||
|
return <ResetPage token={RESET_TOKEN} />;
|
||||||
|
}
|
||||||
|
if (REVIEW_ID) {
|
||||||
|
return <ReviewPage ticketId={REVIEW_ID} />;
|
||||||
|
}
|
||||||
|
if (SHARE_ID) {
|
||||||
|
return <SharePage publicId={SHARE_ID} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the sidebar list; resolves the id that ends up selected so
|
||||||
|
// callers can sync the URL (/chat/<id>) with it.
|
||||||
|
const refreshConvs = useCallback(async (select?: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const res = await api.convs();
|
||||||
|
setConvs(res.conversations);
|
||||||
|
setConvsReady(true);
|
||||||
|
let next: string | null = null;
|
||||||
|
if (select) {
|
||||||
|
next = res.conversations.some((c) => c.id === select) ? select : null;
|
||||||
|
} else {
|
||||||
|
const cur = activeIdRef.current;
|
||||||
|
next =
|
||||||
|
cur && res.conversations.some((c) => c.id === cur)
|
||||||
|
? cur
|
||||||
|
: (res.conversations[0]?.id ?? null);
|
||||||
|
}
|
||||||
|
setActiveId(next);
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
// ignore (session errors reload the page via api layer)
|
||||||
|
return activeIdRef.current;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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 }[],
|
||||||
|
reportsEnabled: true,
|
||||||
|
usernameChangeEnabled: true,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
|
setModels(m.models);
|
||||||
|
if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(res.user.username)));
|
||||||
|
setConvs(c.conversations);
|
||||||
|
setConvsReady(true);
|
||||||
|
// Deep link: /chat/<id> opens that conversation directly.
|
||||||
|
const urlId = chatIdFromPath(window.location.pathname);
|
||||||
|
if (urlId && c.conversations.some((x) => x.id === urlId)) {
|
||||||
|
setActiveId(urlId);
|
||||||
|
} else if (urlId) {
|
||||||
|
navigate("/chat", true); // unknown or deleted id
|
||||||
|
} else if (c.conversations.length > 0) {
|
||||||
|
const first = c.conversations[0]!.id;
|
||||||
|
setActiveId(first);
|
||||||
|
navigate(`/chat/${first}`, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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 (isChatPath(path)) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Browser back/forward (or manual URL edit): the URL is the source of
|
||||||
|
// truth for the selected conversation. Own navigations are no-ops here
|
||||||
|
// (state already matches), so this never fights the handlers below.
|
||||||
|
// Exception: while the first reply of a new chat is being created/sent,
|
||||||
|
// the URL is still bare /chat but the selection already points at the new
|
||||||
|
// id — adopting the bare URL would drop the selection and the next
|
||||||
|
// follow-up would fork a brand new conversation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || checking || !isChatPath(path)) return;
|
||||||
|
const id = chatIdFromPath(path);
|
||||||
|
if (id === activeId) return;
|
||||||
|
if (id === null && sending && sendingForRef.current !== null && activeId === sendingForRef.current) return;
|
||||||
|
// Back to bare /chat (id null): empty composer, unless an archived
|
||||||
|
// chat is being viewed — archived views always live on bare /chat.
|
||||||
|
if (id !== null || !archivedConv) {
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(id);
|
||||||
|
setMobileNav(false);
|
||||||
|
}
|
||||||
|
}, [path, user, checking, activeId, archivedConv, sending]);
|
||||||
|
|
||||||
|
// Unknown /chat/<id> (deleted elsewhere, typo): bounce to /chat once
|
||||||
|
// the list is loaded. Archived views always live on bare /chat.
|
||||||
|
// Exception: a freshly created id may reach the URL before the sidebar
|
||||||
|
// list refresh caught up — never bounce the conversation being sent to.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || checking || !convsReady || !isChatPath(path)) return;
|
||||||
|
const id = chatIdFromPath(path);
|
||||||
|
if (id && !convs.some((c) => c.id === id)) {
|
||||||
|
if (sendingForRef.current === id) return;
|
||||||
|
navigate("/chat", true);
|
||||||
|
}
|
||||||
|
}, [path, user, checking, convsReady, convs]);
|
||||||
|
|
||||||
|
// Load messages for active conversation
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
|
if (!activeId) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The conversation being sent to owns its optimistic messages until the
|
||||||
|
// stream lands — the server list is still empty mid-flight, so a refetch
|
||||||
|
// here would blank the fresh exchange (displayed as a recreated chat).
|
||||||
|
if (sendingForRef.current === activeId) return;
|
||||||
|
const id = activeId;
|
||||||
|
api
|
||||||
|
.getConv(id)
|
||||||
|
.then((res) => {
|
||||||
|
if (activeIdRef.current !== id || sendingForRef.current === id) return;
|
||||||
|
setMessages(res.messages);
|
||||||
|
// A model error is persisted browser-side (never server-side, so
|
||||||
|
// history stays clean): re-create the trailing error bubble + retry
|
||||||
|
// state after a refresh. Stale entries (conversation moved on) drop.
|
||||||
|
const entry = getFailedEntry(id);
|
||||||
|
if (entry && entry.index === res.messages.length) {
|
||||||
|
setMessages([...res.messages, { role: "assistant", content: t("attach.chatError") }]);
|
||||||
|
setFailed({ index: entry.index, convId: id });
|
||||||
|
setRetryCount(entry.retryCount);
|
||||||
|
} else if (entry) {
|
||||||
|
clearFailedEntry(id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setMessages([]));
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
|
// Follow OS theme when account theme is auto; follow local time (07:00–19:00)
|
||||||
|
// when account theme is sunset — re-applied at each day/night boundary.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.theme === "sunset") {
|
||||||
|
let timer: number | undefined;
|
||||||
|
const schedule = () => {
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
applyTheme("sunset");
|
||||||
|
schedule();
|
||||||
|
}, msUntilNextSolarSwitch());
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
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(pickModel(m.models, getSavedModel(u.username)));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
api
|
||||||
|
.tools()
|
||||||
|
.then((t) => {
|
||||||
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
// Land directly on the latest conversation's own URL.
|
||||||
|
refreshConvs().then((id) => {
|
||||||
|
if (id) navigate(`/chat/${id}`, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
// Single flight: another conversation is streaming — say so instead
|
||||||
|
// of silently swallowing the submit.
|
||||||
|
if (sending && sendingForRef.current && sendingForRef.current !== activeIdRef.current) {
|
||||||
|
pushToast(t("chat.busyOther"), { icon: "model" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let convId = activeId;
|
||||||
|
if (!convId) {
|
||||||
|
try {
|
||||||
|
const res = await api.createConv({ model });
|
||||||
|
convId = res.conversation.id;
|
||||||
|
// Claim the new conversation synchronously (before any awaited
|
||||||
|
// render): the URL is still bare /chat while the selection already
|
||||||
|
// points at the new id, and sendingForRef shields both until the
|
||||||
|
// stream lands. Then refresh the list and move the URL to /chat/<id>.
|
||||||
|
setFlight(convId);
|
||||||
|
setActiveId(convId);
|
||||||
|
await refreshConvs(convId);
|
||||||
|
navigate(`/chat/${convId}`);
|
||||||
|
} catch (err) {
|
||||||
|
setFlight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFlight(convId);
|
||||||
|
}
|
||||||
|
const userMsg: ChatMessage = { role: "user", content: text, model };
|
||||||
|
// 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");
|
||||||
|
// A trailing error bubble is display-only: never send the fake error
|
||||||
|
// text to the model, whether it was just created or restored.
|
||||||
|
const cleanBase =
|
||||||
|
failed && failed.convId === convId ? messages.filter((_, i) => i !== failed.index) : messages;
|
||||||
|
const history = [...cleanBase, userMsg];
|
||||||
|
setMessages(history);
|
||||||
|
setAttachments([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
|
setSending(true);
|
||||||
|
setStreaming("");
|
||||||
|
try {
|
||||||
|
const full = await streamReply(history, convId!);
|
||||||
|
// Only apply to the conversation still on screen: if the user
|
||||||
|
// switched chats mid-stream, the reply is already persisted
|
||||||
|
// server-side and the other view reloads it on selection.
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
|
}
|
||||||
|
setStreaming("");
|
||||||
|
clearFailedEntry(convId!);
|
||||||
|
// Explicit select: never let the post-stream refresh drift the
|
||||||
|
// selection (or the URL) away from this conversation.
|
||||||
|
await refreshConvs(convId!);
|
||||||
|
if (activeIdRef.current === convId) navigate(`/chat/${convId}`);
|
||||||
|
} catch {
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([
|
||||||
|
...history,
|
||||||
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
|
]);
|
||||||
|
setFailed({ index: history.length, convId: convId! });
|
||||||
|
}
|
||||||
|
// Persist the failure browser-side so the bubble + retry survive a
|
||||||
|
// refresh (nothing fake is written server-side).
|
||||||
|
setFailedEntry(convId!, { index: history.length, retryCount: 0, at: Date.now() });
|
||||||
|
setStreaming("");
|
||||||
|
} finally {
|
||||||
|
if (sendingForRef.current === convId) setFlight(null);
|
||||||
|
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;
|
||||||
|
const nextCount = retryCount + 1;
|
||||||
|
setFlight(convId);
|
||||||
|
setMessages(history);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(nextCount);
|
||||||
|
setSending(true);
|
||||||
|
setStreaming("");
|
||||||
|
try {
|
||||||
|
const full = await streamReply(history, convId);
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
|
}
|
||||||
|
setRetryCount(0);
|
||||||
|
clearFailedEntry(convId);
|
||||||
|
await refreshConvs(convId);
|
||||||
|
if (activeIdRef.current === convId) navigate(`/chat/${convId}`);
|
||||||
|
} catch {
|
||||||
|
if (activeIdRef.current === convId) {
|
||||||
|
setMessages([
|
||||||
|
...history,
|
||||||
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
|
]);
|
||||||
|
setFailed({ index: history.length, convId });
|
||||||
|
}
|
||||||
|
setFailedEntry(convId, { index: history.length, retryCount: nextCount, at: Date.now() });
|
||||||
|
setStreaming("");
|
||||||
|
} finally {
|
||||||
|
if (sendingForRef.current === convId) setFlight(null);
|
||||||
|
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);
|
||||||
|
clearFailedEntry(c.id);
|
||||||
|
if (activeId === c.id) {
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
if (archivedConv?.id === c.id) {
|
||||||
|
setArchivedConv(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
|
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
||||||
|
} 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([]);
|
||||||
|
}
|
||||||
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
|
pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" });
|
||||||
|
} 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([]);
|
||||||
|
// Archived views always live on bare /chat (no per-conversation URL).
|
||||||
|
navigate("/chat");
|
||||||
|
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;
|
||||||
|
pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" });
|
||||||
|
setArchivedConv(null);
|
||||||
|
await refreshConvs(id);
|
||||||
|
navigate(`/chat/${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([]);
|
||||||
|
navigate("/chat");
|
||||||
|
}
|
||||||
|
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
|
||||||
|
: pickModel(m.models, user ? getSavedModel(user.username) : null),
|
||||||
|
);
|
||||||
|
} 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 (!isChatPath(path)) {
|
||||||
|
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={() => {
|
||||||
|
// Bare /chat with an empty composer; the conversation is
|
||||||
|
// created lazily on send (then the URL becomes /chat/<id>).
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
|
navigate("/chat");
|
||||||
|
setMobileNav(false);
|
||||||
|
}}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(id);
|
||||||
|
navigate(`/chat/${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 / thinking / sending UI lives only in the conversation
|
||||||
|
// being generated for — anywhere else renders clean and stays usable.
|
||||||
|
streaming={sendingFor === activeId ? streaming : ""}
|
||||||
|
sending={sending && sendingFor === activeId}
|
||||||
|
models={models}
|
||||||
|
model={model}
|
||||||
|
onModelChange={(id) => {
|
||||||
|
setModel(id);
|
||||||
|
setSavedModel(user.username, id);
|
||||||
|
pushToast(t("toast.modelChanged", { label: models.find((m) => m.id === id)?.label ?? id }), {
|
||||||
|
icon: "model",
|
||||||
|
tag: "model",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSend={send}
|
||||||
|
onShare={() => active && setShareConv(active)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onUnarchive={unarchiveActive}
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
|
onExpandSidebar={() => setSidebarCollapsed(false)}
|
||||||
|
onOpenNav={() => setMobileNav(true)}
|
||||||
|
attachments={attachments}
|
||||||
|
attachError={attachError}
|
||||||
|
ocrAvailable={ocrAvailable}
|
||||||
|
reportsEnabled={reportsEnabled}
|
||||||
|
onRemoveAttachment={(id) =>
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||||
|
}
|
||||||
|
onPickFile={handlePickFile}
|
||||||
|
failedIndex={failed && failed.convId === activeId ? 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)));
|
||||||
|
pushToast(t("toast.convUpdated", { title: c.title }), { icon: "edit" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
usernameChangeEnabled={usernameChangeEnabled}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSaved={(u) => {
|
||||||
|
if (u.theme !== user?.theme) {
|
||||||
|
pushToast(t("toast.themeChanged"), { icon: "theme", tag: "theme" });
|
||||||
|
} else {
|
||||||
|
pushToast(t("toast.settingsSaved"), { icon: "check" });
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<Toasts />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
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 { pushToast } from "../lib/toasts.ts";
|
||||||
|
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"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message ids already applied (WS echo + HTTP response race). Shared by the
|
||||||
|
* list (count bumps) and the open thread (appends) so each message lands
|
||||||
|
* exactly once no matter which arrives first.
|
||||||
|
*/
|
||||||
|
const seenAccessMsgIds = new Set<number>();
|
||||||
|
function markAccessMsgSeen(msg: AccessMessage): boolean {
|
||||||
|
if (seenAccessMsgIds.has(msg.id)) return false;
|
||||||
|
seenAccessMsgIds.add(msg.id);
|
||||||
|
if (seenAccessMsgIds.size > 1000) {
|
||||||
|
const oldest = seenAccessMsgIds.values().next().value;
|
||||||
|
if (oldest !== undefined) seenAccessMsgIds.delete(oldest);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
// State mirrors for the WS handler (no side effects inside updaters).
|
||||||
|
const rowsRef = useRef(requests);
|
||||||
|
const selectedRef = useRef(selectedId);
|
||||||
|
rowsRef.current = requests;
|
||||||
|
selectedRef.current = selectedId;
|
||||||
|
function setRows(fn: (prev: (AccessRequest & { message_count: number })[]) => (AccessRequest & { message_count: number })[]): void {
|
||||||
|
setRequests((prev) => {
|
||||||
|
const next = fn(prev);
|
||||||
|
rowsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(select?: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.adminAccessList();
|
||||||
|
setRows(() => 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 full payloads, so rows update
|
||||||
|
// incrementally with zero HTTP (shares its socket with the AdminCenter
|
||||||
|
// badge via the pool). Full resync only on (re)connect (offline gap).
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === "pong") return;
|
||||||
|
if (evt.type === "access_created") {
|
||||||
|
markAccessMsgSeen(evt.message);
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.some((r) => r.id === evt.request_id)
|
||||||
|
? prev
|
||||||
|
: [{ ...evt.request, message_count: evt.message_count }, ...prev],
|
||||||
|
);
|
||||||
|
} else if (evt.type === "access_message") {
|
||||||
|
if (!markAccessMsgSeen(evt.message)) return;
|
||||||
|
const row = rowsRef.current.find((r) => r.id === evt.request_id);
|
||||||
|
if (row && evt.message.author === "user" && selectedRef.current !== evt.request_id) {
|
||||||
|
pushToast(t("live.newMessage", { user: row.username }), { icon: "mail" });
|
||||||
|
}
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === evt.request_id ? { ...r, message_count: r.message_count + 1 } : r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (evt.type === "access_status") {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === evt.request_id ? { ...evt.request, message_count: r.message_count } : r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Report events: the ReportsPanel owns them; badges own the counts.
|
||||||
|
},
|
||||||
|
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} onUpdated={(updated) => setRows((prev) => prev.map((x) => x.id === updated.id ? { ...updated, message_count: x.message_count } : x))} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketDetail({ request, onUpdated }: { request: AccessRequest; onUpdated: (r: AccessRequest) => 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReason(request.reason);
|
||||||
|
setFreshKey(null);
|
||||||
|
// Live thread: requester replies arrive instantly with their full
|
||||||
|
// payload (no refetch); status changes made elsewhere arrive on the
|
||||||
|
// parent's firehose and flow back down as props. Full reload only on
|
||||||
|
// (re)connect (offline gap).
|
||||||
|
async function fetchOnce() {
|
||||||
|
try {
|
||||||
|
const res = await api.adminAccessGet(request.id);
|
||||||
|
for (const m of res.messages) markAccessMsgSeen(m);
|
||||||
|
setMessages(res.messages);
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchOnce();
|
||||||
|
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Report events only travel the admin firehose — never ticket sockets.
|
||||||
|
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
|
||||||
|
if (evt.request_id !== request.id) return;
|
||||||
|
if (evt.type === "access_message") {
|
||||||
|
if (!markAccessMsgSeen(evt.message)) return;
|
||||||
|
setMessages((prev) => [...prev, evt.message]);
|
||||||
|
}
|
||||||
|
// access_status flows via the parent firehose -> onUpdated -> props.
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
onUpdated(res.request);
|
||||||
|
} 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);
|
||||||
|
// The broadcast echo of our own message may already be applied —
|
||||||
|
// the shared seen-set keeps exactly one copy either way.
|
||||||
|
if (markAccessMsgSeen(res.message)) setMessages((prev) => [...prev, res.message]);
|
||||||
|
setDraft("");
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Plus, Send, ShieldAlert, SlidersHorizontal, Trash2, TrendingUp, Users } from "lucide-react";
|
||||||
|
import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus, type VoicePreview } from "../lib/api.ts";
|
||||||
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
|
import { AdminPanel } from "./AdminPanel.tsx";
|
||||||
|
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||||
|
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||||
|
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||||
|
import { StatsSection } from "./StatsSection.tsx";
|
||||||
|
import { Toasts } from "./Toasts.tsx";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
|
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Spinner, Switch } from "./ui.tsx";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||||
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
|
import { OPENAI_STATIC_VOICES } from "../lib/voice.ts";
|
||||||
|
|
||||||
|
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
|
||||||
|
|
||||||
|
const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "stats", "features", "mail"];
|
||||||
|
|
||||||
|
function validTab(v: string | null | undefined): Tab {
|
||||||
|
return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: string) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const dark = resolveThemeDark(theme);
|
||||||
|
root.classList.toggle("dark", dark);
|
||||||
|
root.style.colorScheme = dark ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||||
|
const [tab, setTab] = useState<Tab>(() => validTab(initialTab));
|
||||||
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||||
|
const [openReportCount, setOpenReportCount] = useState(0);
|
||||||
|
// Deep links (/admin, /admin/models, …): unknown sections bounce to /admin.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTab !== null && initialTab !== undefined && !VALID_TABS.includes(initialTab)) {
|
||||||
|
navigate("/admin", true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function selectTab(id: Tab): void {
|
||||||
|
setTab(id);
|
||||||
|
navigate(id === "accounts" ? "/admin" : `/admin/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
|
||||||
|
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
|
||||||
|
const resyncBadgesRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
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: "stats", label: t("center.tabStats"), icon: TrendingUp },
|
||||||
|
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||||
|
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
api.verify()
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.user.isAdmin) {
|
||||||
|
setAllowed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAllowed(true);
|
||||||
|
applyTheme(r.user.theme);
|
||||||
|
if (r.user.theme === "sunset") {
|
||||||
|
const schedule = (): void => {
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
applyTheme("sunset");
|
||||||
|
schedule();
|
||||||
|
}, msUntilNextSolarSwitch());
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||||
|
})
|
||||||
|
.catch(() => setAllowed(false));
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Live badges on the Access/Reports tabs: the admin firehose pushes full
|
||||||
|
// payloads, so the open counts update incrementally with zero HTTP.
|
||||||
|
// Resync on (re)connect covers events missed while offline.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
const accessById = new Map<string, AccessStatus>();
|
||||||
|
const reportById = new Map<string, { status: ReportStatus; shadowbanned: number }>();
|
||||||
|
function recount() {
|
||||||
|
let access = 0;
|
||||||
|
for (const s of accessById.values()) if (s === "pending" || s === "reviewing") access++;
|
||||||
|
let reports = 0;
|
||||||
|
for (const r of reportById.values()) {
|
||||||
|
if ((r.status === "open" || r.status === "reviewing") && r.shadowbanned !== 1) reports++;
|
||||||
|
}
|
||||||
|
setOpenAccessCount(access);
|
||||||
|
setOpenReportCount(reports);
|
||||||
|
}
|
||||||
|
async function resync() {
|
||||||
|
try {
|
||||||
|
const res = await api.adminAccessList();
|
||||||
|
accessById.clear();
|
||||||
|
for (const r of res.requests) accessById.set(r.id, r.status);
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last counts.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.adminReportList();
|
||||||
|
reportById.clear();
|
||||||
|
for (const r of res.reports) {
|
||||||
|
reportById.set(r.id, { status: r.status, shadowbanned: r.reporter_shadowbanned });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last counts.
|
||||||
|
}
|
||||||
|
recount();
|
||||||
|
}
|
||||||
|
resyncBadgesRef.current = resync;
|
||||||
|
resync();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === "pong" || evt.type === "access_message") return;
|
||||||
|
if (evt.type === "access_created") {
|
||||||
|
accessById.set(evt.request_id, evt.request.status);
|
||||||
|
recount();
|
||||||
|
pushToast(t("live.newRequest", { user: evt.request.username }), { icon: "inbox" });
|
||||||
|
} else if (evt.type === "access_status") {
|
||||||
|
accessById.set(evt.request_id, evt.request.status);
|
||||||
|
recount();
|
||||||
|
} else if (evt.type === "report_created") {
|
||||||
|
reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: evt.report.reporter_shadowbanned });
|
||||||
|
recount();
|
||||||
|
pushToast(t("live.newReport", { reason: evt.report.reason }), { icon: "mail" });
|
||||||
|
} else if (evt.type === "report_status") {
|
||||||
|
const prev = reportById.get(evt.report.id);
|
||||||
|
reportById.set(evt.report.id, {
|
||||||
|
status: evt.report.status,
|
||||||
|
shadowbanned: prev?.shadowbanned ?? evt.report.reporter_shadowbanned,
|
||||||
|
});
|
||||||
|
recount();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSync: () => {
|
||||||
|
resync();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allowed]);
|
||||||
|
|
||||||
|
async function toggle(patch: AdminSettingsPatch) {
|
||||||
|
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((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb.id}
|
||||||
|
onClick={() => selectTab(tb.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||||
|
tab === tb.id
|
||||||
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
|
: "opacity-60 hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tb.icon size={15} />
|
||||||
|
{tb.label}
|
||||||
|
{tb.id === "access" && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={`${tb.label}: ${openAccessCount}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||||
|
openAccessCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tb.id === "reports" && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={`${tb.label}: ${openReportCount}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||||
|
openReportCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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 onShadowbanChanged={() => resyncBadgesRef.current()} />
|
||||||
|
</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 === "stats" && <StatsSection />}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<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.reportsTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.reportsDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.reportsTitle")} checked={settings?.reportsEnabled ?? false} onChange={(v) => toggle({ reportsEnabled: 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.usernameChangeTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.usernameChangeDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.usernameChangeTitle")} checked={settings?.usernameChangeEnabled ?? false} onChange={(v) => toggle({ usernameChangeEnabled: 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.ttsTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.ttsDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.ttsTitle")} checked={settings?.ttsEnabled ?? false} onChange={(v) => toggle({ ttsEnabled: 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>}
|
||||||
|
<VoicePreviewsCard />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "mail" && <MailPanel />}
|
||||||
|
</main>
|
||||||
|
<Toasts />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin voice-preview lines (Settings → Voice for users). Empty = reset. */
|
||||||
|
function VoicePreviewsCard() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [lines, setLines] = useState<VoicePreview[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [ttsProvider, setTtsProvider] = useState<string | null>(null);
|
||||||
|
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminGetSettings()
|
||||||
|
.then((r) => {
|
||||||
|
setLines(r.settings.voicePreviews);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setLoaded(true));
|
||||||
|
api.ttsInfo()
|
||||||
|
.then((r) => {
|
||||||
|
setServerVoices(r.voices);
|
||||||
|
setTtsProvider(r.provider);
|
||||||
|
setTtsAvailable(r.available);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(next: VoicePreview[]) {
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings({ voicePreviews: next });
|
||||||
|
setLines(res.settings.voicePreviews);
|
||||||
|
setMsg({ ok: true, text: t("vfeat.saved") });
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok: false, text: e instanceof Error ? e.message : t("vfeat.invalid") });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit one line after editing (empty text drops the row). */
|
||||||
|
function commit(i: number) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line) return;
|
||||||
|
const text = line.text.trim();
|
||||||
|
if (text === "") {
|
||||||
|
const next = lines.filter((_, j) => j !== i);
|
||||||
|
if (next.length === 0) {
|
||||||
|
setMsg({ ok: false, text: t("vfeat.invalid") });
|
||||||
|
void refreshLines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void save(lines.map((l, j) => (j === i ? { ...l, text } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(i: number, p: Partial<VoicePreview>) {
|
||||||
|
setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...p } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLines() {
|
||||||
|
try {
|
||||||
|
const r = await api.adminGetSettings();
|
||||||
|
setLines(r.settings.voicePreviews);
|
||||||
|
} catch {
|
||||||
|
// keep current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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("vfeat.title")}</p>
|
||||||
|
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("vfeat.desc")}</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<li key={i} className="rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={line.text}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={500}
|
||||||
|
aria-label={t("vfeat.line", { n: i + 1 })}
|
||||||
|
onChange={(e) => patch(i, { text: e.target.value })}
|
||||||
|
onBlur={() => commit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || lines.length <= 1}
|
||||||
|
onClick={() => void save(lines.filter((_, j) => j !== i))}
|
||||||
|
aria-label={t("vfeat.remove", { n: i + 1 })}
|
||||||
|
className="shrink-0 rounded-full p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-30 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-end gap-x-4 gap-y-2">
|
||||||
|
<span className="w-36">
|
||||||
|
<Field label={t("vfeat.timbre")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("vfeat.timbre")}
|
||||||
|
value={line.timbre}
|
||||||
|
onChange={(v) => {
|
||||||
|
patch(i, { timbre: v as VoicePreview["timbre"] });
|
||||||
|
// Persist timbre/rate/pitch tweaks live (text commits on blur).
|
||||||
|
const next = lines.map((l, j) => (j === i ? { ...l, timbre: v as VoicePreview["timbre"] } : l));
|
||||||
|
if (next[i]!.text.trim() !== "") void save(next);
|
||||||
|
}}
|
||||||
|
align="left"
|
||||||
|
options={(["masculine", "feminine", "any"] as const).map((v) => ({ value: v, label: t(`voice.timbre.${v}` as StringKey) }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</span>
|
||||||
|
<span className="w-40">
|
||||||
|
<Field label={t("vfeat.voice")} hint={ttsAvailable ? (ttsProvider ? `${ttsProvider} · ${t("vfeat.voiceHint")}` : t("vfeat.voiceHint")) : t("vfeat.noProvider")}>
|
||||||
|
{serverVoices.length > 0 || ttsProvider !== "elevenlabs" ? (
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("vfeat.voice")}
|
||||||
|
value={line.voice}
|
||||||
|
onChange={(v) => {
|
||||||
|
patch(i, { voice: v });
|
||||||
|
const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l));
|
||||||
|
if (next[i]!.text.trim() !== "") void save(next);
|
||||||
|
}}
|
||||||
|
align="left"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("vfeat.voicePh") },
|
||||||
|
...(serverVoices.length > 0
|
||||||
|
? serverVoices.map((v) => ({ value: v.id, label: v.name }))
|
||||||
|
: OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={line.voice}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder={t("vfeat.voicePh")}
|
||||||
|
aria-label={t("vfeat.voice")}
|
||||||
|
onChange={(e) => patch(i, { voice: e.target.value })}
|
||||||
|
onBlur={() => commit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</span>
|
||||||
|
<label className="min-w-32 flex-1 text-xs opacity-80">
|
||||||
|
<span className="mb-1 block font-medium">{t("voice.rate")} · {line.rate.toFixed(2)}×</span>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={1.5} step={0.05} value={line.rate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patch(i, { rate: Number(e.target.value) })}
|
||||||
|
onMouseUp={() => commitSliders(i)}
|
||||||
|
onTouchEnd={() => commitSliders(i)}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.rate")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-32 flex-1 text-xs opacity-80">
|
||||||
|
<span className="mb-1 block font-medium">{t("voice.pitch")} · {line.pitch.toFixed(2)}×</span>
|
||||||
|
<input
|
||||||
|
type="range" min={0.5} max={2} step={0.05} value={line.pitch}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patch(i, { pitch: Number(e.target.value) })}
|
||||||
|
onMouseUp={() => commitSliders(i)}
|
||||||
|
onTouchEnd={() => commitSliders(i)}
|
||||||
|
className="w-full accent-[rgb(var(--ka-accent-600))]"
|
||||||
|
aria-label={t("voice.pitch")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{lines.length < 6 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setLines((prev) => [...prev, { text: "", lang: "auto", rate: 1, pitch: 1, timbre: "any" as const, voice: "" }])}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {t("vfeat.add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void save([])}>
|
||||||
|
{t("vfeat.reset")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<p role={msg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", msg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function commitSliders(i: number) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line || line.text.trim() === "") return;
|
||||||
|
void save(lines.map((l, j) => (j === i ? { ...l } : l)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [recMax, setRecMax] = useState("5");
|
||||||
|
const [recWindow, setRecWindow] = useState("60");
|
||||||
|
const [recSaving, setRecSaving] = useState(false);
|
||||||
|
const [recMsg, setRecMsg] = 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")));
|
||||||
|
api.adminGetSettings().then((r) => {
|
||||||
|
setRecMax(String(r.settings.recoveryLimitMax));
|
||||||
|
setRecWindow(String(r.settings.recoveryLimitWindowMin));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecoveryLimits() {
|
||||||
|
const max = Number(recMax);
|
||||||
|
const win = Number(recWindow);
|
||||||
|
if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(win) || win < 1 || win > 1440) {
|
||||||
|
setRecMsg({ ok: false, text: t("rec.invalid") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecSaving(true);
|
||||||
|
setRecMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings({ recoveryLimitMax: max, recoveryLimitWindowMin: win });
|
||||||
|
setRecMax(String(res.settings.recoveryLimitMax));
|
||||||
|
setRecWindow(String(res.settings.recoveryLimitWindowMin));
|
||||||
|
setRecMsg({ ok: true, text: t("rec.saved") });
|
||||||
|
} catch (e) {
|
||||||
|
setRecMsg({ ok: false, text: e instanceof Error ? e.message : t("common.saveFailed") });
|
||||||
|
} finally {
|
||||||
|
setRecSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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("rec.title")}</p>
|
||||||
|
<p className="mt-0.5 text-sm opacity-60">{t("rec.desc")}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("rec.max")} hint={t("rec.maxHint")}>
|
||||||
|
<Input type="number" min={1} max={100} value={recMax} onChange={(e) => setRecMax(e.target.value)} disabled={recSaving} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("rec.window")} hint={t("rec.windowHint")}>
|
||||||
|
<Input type="number" min={1} max={1440} value={recWindow} onChange={(e) => setRecWindow(e.target.value)} disabled={recSaving} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={saveRecoveryLimits} disabled={recSaving}>
|
||||||
|
{recSaving ? <Spinner size={14} /> : null} {t("rec.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{recMsg && (
|
||||||
|
<p role={recMsg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", recMsg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
|
||||||
|
{recMsg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
import { useEffect, useState, type FC, type ReactNode } from "react";
|
||||||
|
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Mail, 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);
|
||||||
|
const [resetTarget, setResetTarget] = useState<User | null>(null);
|
||||||
|
const [resetSent, setResetSent] = useState<{ username: string; email: string } | null>(null);
|
||||||
|
const [regenTarget, setRegenTarget] = 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("");
|
||||||
|
setFreshKey(null);
|
||||||
|
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 sendResetLink(u: User) {
|
||||||
|
setError("");
|
||||||
|
setResetSent(null);
|
||||||
|
try {
|
||||||
|
const res = await api.adminResetLink(u.id);
|
||||||
|
setResetSent({ username: u.username, email: res.email });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.resetFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetSent && (
|
||||||
|
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium"><Mail size={15} /> {t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}</p>
|
||||||
|
</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={() => setRegenTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||||
|
<button title={t("admin.resetLink")} onClick={() => setResetTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Mail 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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetTarget !== null}
|
||||||
|
onClose={() => setResetTarget(null)}
|
||||||
|
title={t("admin.resetTitle", { user: resetTarget?.username ?? "" })}
|
||||||
|
message={t("admin.resetMsg", { email: resetTarget?.email || t("admin.noEmail") })}
|
||||||
|
confirmLabel={t("admin.resetConfirm")}
|
||||||
|
onConfirm={() => {
|
||||||
|
const u = resetTarget;
|
||||||
|
setResetTarget(null);
|
||||||
|
if (u) void sendResetLink(u);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={regenTarget !== null}
|
||||||
|
onClose={() => setRegenTarget(null)}
|
||||||
|
title={t("admin.regenTitle", { user: regenTarget?.username ?? "" })}
|
||||||
|
message={t("admin.regenMsg")}
|
||||||
|
confirmLabel={t("admin.regenConfirm")}
|
||||||
|
onConfirm={() => {
|
||||||
|
const u = regenTarget;
|
||||||
|
setRegenTarget(null);
|
||||||
|
if (u) void regenerate(u);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [theme, setTheme] = useState("auto");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setUsername(user.username);
|
||||||
|
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 {
|
||||||
|
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
if (normalizedUsername !== (user.username ?? "").toLowerCase()) patch.username = normalizedUsername;
|
||||||
|
if (displayName.trim() !== (user.displayName ?? "")) patch.displayName = displayName.trim();
|
||||||
|
if (avatarUrl.trim() !== (user.avatarUrl ?? "")) patch.avatarUrl = avatarUrl.trim();
|
||||||
|
if (email.trim() !== (user.email ?? "")) patch.email = email.trim();
|
||||||
|
if (theme !== (user.theme ?? "auto")) patch.theme = theme;
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.adminPatch(user.id, patch);
|
||||||
|
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("settings.username")} hint={t("settings.usernameHint")}>
|
||||||
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} />
|
||||||
|
</Field>
|
||||||
|
<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: "sunset", label: t("common.themeSunset") },
|
||||||
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, Mic, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, Square, User as UserIcon, Volume2, VolumeX, 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, copyText, 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 { fmtDuration, getVoicePrefs, listTtsVoices, playText, recognizerCtor, stopPlayback, sttSupported, type Recognizer } from "../lib/voice.ts";
|
||||||
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
|
|
||||||
|
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
||||||
|
|
||||||
|
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry, speaking, onToggleSpeak }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void; speaking?: boolean; onToggleSpeak?: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const isUser = msg.role === "user";
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
function copy() {
|
||||||
|
copyText(msg.content).then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function searchWeb() {
|
||||||
|
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-2 sm: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-[88%] sm: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={cn("rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900", speaking && "speaking-glow")}>
|
||||||
|
<Markdown text={msg.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
||||||
|
{msg.model ? (
|
||||||
|
<span
|
||||||
|
title={msg.model}
|
||||||
|
className="mr-1 max-w-40 truncate rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-[11px] opacity-80 dark:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{msg.model}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{onToggleSpeak && (
|
||||||
|
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={onToggleSpeak}>
|
||||||
|
{speaking ? <VolumeX size={14} className="speaking-orb text-accent-500" /> : <Volume2 size={14} />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{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={reportsEnabled ? t("msg.report") : t("msg.reportDisabled")}
|
||||||
|
onClick={() => onReport(index)}
|
||||||
|
disabled={!reportsEnabled}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live voice waveform: mirrored gradient bars glowing with the input level. */
|
||||||
|
function Waveform({ levels }: { levels: number[] }) {
|
||||||
|
const BARS = 48;
|
||||||
|
const padded = [...Array(Math.max(0, BARS - levels.length)).fill(0), ...levels].slice(-BARS) as number[];
|
||||||
|
return (
|
||||||
|
<div className="flex h-12 items-center gap-[3px] px-2 pt-1" aria-hidden>
|
||||||
|
{padded.map((lv, i) => {
|
||||||
|
const mid = (BARS - 1) / 2;
|
||||||
|
const edge = 1 - Math.abs(i - mid) / mid; // taller in the middle
|
||||||
|
const h = Math.max(6, Math.min(100, (8 + lv * 92) * (0.35 + edge * 0.65)));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="wave-bar w-full rounded-full"
|
||||||
|
style={{
|
||||||
|
height: `${h}%`,
|
||||||
|
opacity: 0.35 + lv * 0.65,
|
||||||
|
background: "linear-gradient(to top, rgb(var(--ka-accent-600)), rgb(var(--ka-accent-300)))",
|
||||||
|
boxShadow: lv > 0.12 ? "0 0 8px rgb(var(--ka-accent-500) / 0.55)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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,
|
||||||
|
reportsEnabled = true,
|
||||||
|
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;
|
||||||
|
reportsEnabled?: 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);
|
||||||
|
// TTS playback: which assistant bubble is speaking (null = silent).
|
||||||
|
const [speakingKey, setSpeakingKey] = useState<number | null>(null);
|
||||||
|
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
|
||||||
|
const ttsServerRef = useRef(false);
|
||||||
|
// Voice input: live recording session (null = composer idle).
|
||||||
|
const [rec, setRec] = useState<{
|
||||||
|
stream: MediaStream;
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
recog: Recognizer | null;
|
||||||
|
startTs: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [recLevels, setRecLevels] = useState<number[]>([]);
|
||||||
|
const [recElapsed, setRecElapsed] = useState(0);
|
||||||
|
const [recText, setRecText] = useState("");
|
||||||
|
const recRaf = useRef(0);
|
||||||
|
const recTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const micOn = sttSupported();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listTtsVoices().then((v) => { voicesRef.current = v; });
|
||||||
|
api.ttsInfo().then((r) => { ttsServerRef.current = r.available; }).catch(() => {});
|
||||||
|
return () => stopPlayback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// New message / conversation: stop any playback (never leak speech).
|
||||||
|
useEffect(() => {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conv?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelRec(false);
|
||||||
|
stopPlayback();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleSpeak(index: number, text: string) {
|
||||||
|
if (speakingKey === index) {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefs = getVoicePrefs();
|
||||||
|
playText({
|
||||||
|
text,
|
||||||
|
engine: prefs.engine,
|
||||||
|
browser: { voices: voicesRef.current, lang: prefs.lang, rate: prefs.rate, pitch: prefs.pitch, voiceURI: prefs.voiceURI },
|
||||||
|
serverVoice: prefs.serverVoice ?? undefined,
|
||||||
|
serverAvailable: ttsServerRef.current,
|
||||||
|
fetchAudio: (t, v) => api.ttsSpeak(t, v),
|
||||||
|
onend: () => setSpeakingKey(null),
|
||||||
|
onerror: () => setSpeakingKey(null),
|
||||||
|
});
|
||||||
|
setSpeakingKey(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRec(r: NonNullable<typeof rec>) {
|
||||||
|
cancelAnimationFrame(recRaf.current);
|
||||||
|
if (recTimer.current) clearInterval(recTimer.current);
|
||||||
|
recTimer.current = null;
|
||||||
|
try { r.recog?.abort(); } catch { /* ignore */ }
|
||||||
|
try { r.audioCtx.close(); } catch { /* ignore */ }
|
||||||
|
for (const tr of r.stream.getTracks()) {
|
||||||
|
try { tr.stop(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRec(reset = true) {
|
||||||
|
setRec((cur) => {
|
||||||
|
if (cur) cleanupRec(cur);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (reset) {
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecElapsed(0);
|
||||||
|
setRecText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRec() {
|
||||||
|
if (rec || sending || readOnly || !micOn) return;
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const audioCtx = new AudioContext();
|
||||||
|
const src = audioCtx.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 512;
|
||||||
|
src.connect(analyser);
|
||||||
|
const data = new Uint8Array(analyser.fftSize);
|
||||||
|
const Ctor = recognizerCtor();
|
||||||
|
const recog: Recognizer | null = Ctor ? new Ctor() : null;
|
||||||
|
let finals = "";
|
||||||
|
if (recog) {
|
||||||
|
recog.lang = navigator.language || "fr-FR";
|
||||||
|
recog.interimResults = true;
|
||||||
|
recog.continuous = true;
|
||||||
|
recog.onresult = (ev) => {
|
||||||
|
let interim = "";
|
||||||
|
for (const res of Array.from(ev.results)) {
|
||||||
|
if (res.isFinal) finals += res[0]!.transcript;
|
||||||
|
else interim += res[0]!.transcript;
|
||||||
|
}
|
||||||
|
setRecText((finals + " " + interim).trim());
|
||||||
|
};
|
||||||
|
recog.onerror = () => {};
|
||||||
|
try { recog.start(); } catch { /* already started */ }
|
||||||
|
}
|
||||||
|
const startTs = Date.now();
|
||||||
|
const session = { stream, audioCtx, analyser, recog, startTs };
|
||||||
|
setRec(session);
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecText("");
|
||||||
|
setRecElapsed(0);
|
||||||
|
let lastPush = 0;
|
||||||
|
const loop = () => {
|
||||||
|
analyser.getByteTimeDomainData(data);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const v = (data[i]! - 128) / 128;
|
||||||
|
sum += v * v;
|
||||||
|
}
|
||||||
|
const level = Math.min(1, Math.sqrt(sum / data.length) * 2.4);
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastPush > 90) {
|
||||||
|
lastPush = now;
|
||||||
|
setRecLevels((prev) => [...prev.slice(-47), level]);
|
||||||
|
}
|
||||||
|
recRaf.current = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
recRaf.current = requestAnimationFrame(loop);
|
||||||
|
recTimer.current = setInterval(() => setRecElapsed((Date.now() - startTs) / 1000), 500);
|
||||||
|
} catch {
|
||||||
|
// Mic denied / unavailable: stay on the text composer.
|
||||||
|
cancelRec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRec() {
|
||||||
|
const clean = recText.trim();
|
||||||
|
setRec((cur) => {
|
||||||
|
if (cur) cleanupRec(cur);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
// Transcript lands in the composer, editable before sending.
|
||||||
|
if (clean) {
|
||||||
|
setDraft((d) => (d ? `${d.trimEnd()}\n${clean}` : clean));
|
||||||
|
requestAnimationFrame(autoGrow);
|
||||||
|
}
|
||||||
|
setRecText("");
|
||||||
|
setRecLevels([]);
|
||||||
|
setRecElapsed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelGroups: PickerGroup[] = (() => {
|
||||||
|
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||||
|
for (const m of models) {
|
||||||
|
// Own-key (BYOK) models get their own section so they are never
|
||||||
|
// confused with platform models: "openai · personal key".
|
||||||
|
const base = m.group ?? m.driver;
|
||||||
|
const group = m.personal ? `${base} · ${t("chat.personalTag")}` : base;
|
||||||
|
const list = byDriver.get(group) ?? [];
|
||||||
|
list.push({ value: m.id, label: m.label, hint: m.personal ? `${m.id} · ${t("chat.personalTag")}` : 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]);
|
||||||
|
|
||||||
|
// A fresh message ends any playback (the glow never sticks to a stale row).
|
||||||
|
const msgCount = messages.length;
|
||||||
|
useEffect(() => {
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [msgCount]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
stopPlayback();
|
||||||
|
setSpeakingKey(null);
|
||||||
|
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={sending} />
|
||||||
|
{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-4 overflow-y-auto px-4 py-4 sm:space-y-5 sm:px-8 sm:py-6">
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-4 sm: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} reportsEnabled={reportsEnabled} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} speaking={speakingKey === i} onToggleSpeak={m.role === "assistant" ? () => toggleSpeak(i, m.content) : undefined} />
|
||||||
|
))}
|
||||||
|
{streaming !== "" && (
|
||||||
|
<div className="flex gap-2 sm:gap-3">
|
||||||
|
<AssistantAvatar size={32} dynamic />
|
||||||
|
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
|
<span>KisAssistant</span>
|
||||||
|
{model ? (
|
||||||
|
<span title={model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{model}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 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.5 opacity-80">
|
||||||
|
<AssistantAvatar size={32} dynamic />
|
||||||
|
<span className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} />{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={onShare}
|
||||||
|
aria-label={t("sidebar.share")}
|
||||||
|
title={t("sidebar.share")}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-stone-200/70 active:scale-95 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<Share2 size={16} className="opacity-70" />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
) : rec ? (
|
||||||
|
<div className="voice-recorder mx-auto w-full max-w-3xl rounded-[1.75rem] border border-accent-500/40 bg-white p-3 shadow-lg dark:border-accent-500/30 dark:bg-zinc-900">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<span className="rec-dot" aria-hidden />
|
||||||
|
<span className="font-mono text-xs opacity-70">{fmtDuration(recElapsed)}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm opacity-80" aria-live="polite">
|
||||||
|
{recText || t("voice.listening")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cancelRec()}
|
||||||
|
aria-label={t("voice.cancel")}
|
||||||
|
title={t("voice.cancel")}
|
||||||
|
className="grid h-9 w-9 shrink-0 place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={16} className="opacity-70" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopRec}
|
||||||
|
aria-label={t("voice.stop")}
|
||||||
|
title={t("voice.stop")}
|
||||||
|
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 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
<Square size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Waveform levels={recLevels} />
|
||||||
|
</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 = ""; }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startRec}
|
||||||
|
disabled={!micOn || sending}
|
||||||
|
aria-label={t("voice.mic")}
|
||||||
|
title={micOn ? t("voice.mic") : t("voice.micOff")}
|
||||||
|
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 disabled:opacity-40 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Mic size={17} className="opacity-70" />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { CopyButton } from "./ui.tsx";
|
||||||
|
import { highlightCode } from "../lib/highlight.tsx";
|
||||||
|
|
||||||
|
/** Raw source text of a react-markdown <code> element (children may be split). */
|
||||||
|
export function extractCodeText(children: unknown): string {
|
||||||
|
if (children === null || children === undefined) return "";
|
||||||
|
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||||
|
if (Array.isArray(children)) return children.map(extractCodeText).join("");
|
||||||
|
const props = (children as { props?: { children?: unknown } })?.props;
|
||||||
|
if (props && typeof props === "object" && "children" in props) return extractCodeText(props.children);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||||
|
const nodes = useMemo(() => highlightCode(code, lang), [code, lang]);
|
||||||
|
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>{nodes}</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;
|
||||||
|
if (!props || typeof props !== "object") return <pre>{children}</pre>;
|
||||||
|
const className = typeof props.className === "string" ? props.className : "";
|
||||||
|
const lang = className.replace("language-", "");
|
||||||
|
const code = extractCodeText(props.children).replace(/\n$/, "");
|
||||||
|
return <CodeBlock code={code} lang={lang} />;
|
||||||
|
},
|
||||||
|
code({ children }) {
|
||||||
|
return <code>{children}</code>;
|
||||||
|
},
|
||||||
|
table({ children }) {
|
||||||
|
// Wide tables would blow the bubble off-screen on mobile —
|
||||||
|
// scroll them inside their own pane instead.
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-1">
|
||||||
|
<table>{children}</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 max-w-full 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 max-w-full overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] 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}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
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 { NodesSection } from "./OllamaNodes.tsx";
|
||||||
|
import { CatalogSection } from "./OllamaCatalog.tsx";
|
||||||
|
import { Button, Input, Spinner, Switch } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
/** Admin models section: node pool, per-model policy, Ollama library + HF catalog. */
|
||||||
|
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">
|
||||||
|
<NodesSection />
|
||||||
|
|
||||||
|
<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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CatalogSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- per-model policy rows ---------- */
|
||||||
|
|
||||||
|
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 shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
|
<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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{t("models.hourly")}
|
||||||
|
</label>
|
||||||
|
<label className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
|
<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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{t("models.daily")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
label={t("models.enabled")}
|
||||||
|
checked={model.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(v) => void patch({ enabled: v })}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Download, Globe, Server } from "lucide-react";
|
||||||
|
import { api, type EffectiveOllamaNode } from "../lib/api.ts";
|
||||||
|
import { Field, Input, Picker, Spinner } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
interface HfHit {
|
||||||
|
id: string;
|
||||||
|
downloads: number;
|
||||||
|
likes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HfFile {
|
||||||
|
name: string;
|
||||||
|
quant: string;
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the quantization tag from a GGUF filename (…-Q4_K_M.gguf). */
|
||||||
|
function quantOf(filename: string): string | null {
|
||||||
|
const base = filename.split("/").pop() ?? filename;
|
||||||
|
const m = base.match(/[-_.](Q\d(?:_\w+)?|IQ\d_\w+|BF16|F16|F32)(?:\.gguf)$/i);
|
||||||
|
return m ? m[1]!.toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hugging Face GGUF catalog: search, pick a quantization, pull hf.co/… */
|
||||||
|
export function CatalogSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [hits, setHits] = useState<HfHit[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, HfFile[] | null>>({});
|
||||||
|
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
|
||||||
|
const [nodeId, setNodeId] = useState("default");
|
||||||
|
const [pulling, setPulling] = useState<string | null>(null);
|
||||||
|
const [pullStatus, setPullStatus] = useState("");
|
||||||
|
const [pullPct, setPullPct] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ollamaNodes().then((r) => {
|
||||||
|
setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
setHits([]);
|
||||||
|
setSearched(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
timer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://huggingface.co/api/models?search=${encodeURIComponent(query)}&filter=gguf&sort=downloads&direction=-1&limit=15`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { id?: string; downloads?: number; likes?: number }[];
|
||||||
|
setHits(
|
||||||
|
(Array.isArray(data) ? data : [])
|
||||||
|
.filter((m) => typeof m.id === "string")
|
||||||
|
.map((m) => ({ id: m.id as string, downloads: m.downloads ?? 0, likes: m.likes ?? 0 })),
|
||||||
|
);
|
||||||
|
setSearched(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "search failed");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 450);
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
async function loadFiles(id: string) {
|
||||||
|
if (files[id] !== undefined) return;
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: null }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://huggingface.co/api/models/${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) throw new Error(`Hugging Face: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { siblings?: { rfilename?: string }[] };
|
||||||
|
const list: HfFile[] = [];
|
||||||
|
for (const s of data.siblings ?? []) {
|
||||||
|
const name = s.rfilename ?? "";
|
||||||
|
if (!name.toLowerCase().endsWith(".gguf")) continue;
|
||||||
|
const quant = quantOf(name);
|
||||||
|
if (quant) list.push({ name, quant, size: null });
|
||||||
|
}
|
||||||
|
// Biggest quants last (heuristic on the tag, not the file size).
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: list }));
|
||||||
|
} catch {
|
||||||
|
setFiles((prev) => ({ ...prev, [id]: [] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pull(id: string, quant: string) {
|
||||||
|
const ref = `hf.co/${id}:${quant}`;
|
||||||
|
if (pulling) return;
|
||||||
|
setPulling(ref);
|
||||||
|
setError("");
|
||||||
|
setPullStatus(t("catalog.pulling", { name: ref }));
|
||||||
|
setPullPct(null);
|
||||||
|
try {
|
||||||
|
await api.ollamaPull(ref, (p) => {
|
||||||
|
if (p.error) {
|
||||||
|
setError(p.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPullStatus(p.status || ref);
|
||||||
|
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
|
||||||
|
else setPullPct(null);
|
||||||
|
}, { nodeId });
|
||||||
|
setPullStatus(ref);
|
||||||
|
setPullPct(100);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "pull failed");
|
||||||
|
} finally {
|
||||||
|
setPulling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Globe size={14} className="opacity-60" /> {t("catalog.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("catalog.desc")}</p>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-48 flex-1">
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("catalog.searchPh")}
|
||||||
|
aria-label={t("catalog.searchPh")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-44">
|
||||||
|
<Field label={t("catalog.node")}>
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("catalog.node")}
|
||||||
|
icon={Server}
|
||||||
|
value={nodeId}
|
||||||
|
onChange={setNodeId}
|
||||||
|
align="right"
|
||||||
|
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : `${n.name} (w:${n.weight})` }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
{pulling && (
|
||||||
|
<div className="mb-3 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("catalog.pulling", { name: pulling })}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searching && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
{!searching && searched && hits.length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-4 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("catalog.noRes")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{hits.map((h) => (
|
||||||
|
<li key={h.id} className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => loadFiles(h.id)}
|
||||||
|
className="flex w-full items-center gap-3 text-left"
|
||||||
|
aria-expanded={files[h.id] !== undefined}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-mono text-sm font-medium">{h.id}</span>
|
||||||
|
<span className="block text-xs opacity-50">{fmtNum(h.downloads)} {t("catalog.downloads")} · ♥ {fmtNum(h.likes)}</span>
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-xs opacity-60", files[h.id] === undefined && "underline underline-offset-2")}>
|
||||||
|
{t("catalog.files")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{files[h.id] !== undefined && (
|
||||||
|
<div className="mt-2 border-t border-stone-200/70 pt-2 dark:border-zinc-800">
|
||||||
|
{files[h.id] === null ? (
|
||||||
|
<p className="flex items-center gap-2 py-1 text-xs opacity-60"><Spinner size={13} /> {t("common.loading")}</p>
|
||||||
|
) : files[h.id]!.length === 0 ? (
|
||||||
|
<p className="py-1 text-xs opacity-50">{t("catalog.noRes")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{files[h.id]!.map((f) => {
|
||||||
|
const ref = `hf.co/${h.id}:${f.quant}`;
|
||||||
|
return (
|
||||||
|
<li key={f.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => pull(h.id, f.quant)}
|
||||||
|
disabled={pulling !== null}
|
||||||
|
title={ref}
|
||||||
|
className="rounded-full border border-stone-200/70 px-3 py-1.5 font-mono text-xs transition hover:border-accent-500/60 hover:bg-accent-500/5 disabled:opacity-40 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
{pulling === ref ? <Spinner size={12} /> : <Download size={12} className="mr-1 inline" />}
|
||||||
|
{f.quant}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-xs opacity-50">{t("catalog.hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Activity, Pencil, Plus, Server, Trash2, Undo2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type EffectiveOllamaNode,
|
||||||
|
type OllamaNode,
|
||||||
|
type OllamaNodeStats,
|
||||||
|
type OllamaNodeStatus,
|
||||||
|
} from "../lib/api.ts";
|
||||||
|
import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
export function fmtSize(bytes: number): string {
|
||||||
|
if (!bytes || bytes < 0) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let v = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
/** Admin Ollama pool: nodes with firewall-like weights + generation timeout. */
|
||||||
|
export function NodesSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [nodes, setNodes] = useState<OllamaNode[]>([]);
|
||||||
|
const [effective, setEffective] = useState<EffectiveOllamaNode[]>([]);
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, OllamaNodeStatus | null>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [formNode, setFormNode] = useState<OllamaNode | null | "new">(null);
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [timeoutS, setTimeoutS] = useState("300");
|
||||||
|
const [defWeight, setDefWeight] = useState("100");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [list, settings] = await Promise.all([api.ollamaNodes(), api.adminGetSettings()]);
|
||||||
|
setNodes(list.nodes);
|
||||||
|
setEffective(list.effective);
|
||||||
|
setTimeoutS(String(settings.settings.ollamaTimeoutS));
|
||||||
|
setDefWeight(String(settings.settings.ollamaDefaultWeight));
|
||||||
|
// Live probes in parallel; a dead node never blocks the list.
|
||||||
|
const ids = ["default", ...list.nodes.map((n) => n.id)];
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
try {
|
||||||
|
return [id, await api.ollamaNodeStatus(id)] as const;
|
||||||
|
} catch {
|
||||||
|
return [id, null] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const map: Record<string, OllamaNodeStatus | null> = {};
|
||||||
|
for (const [id, st] of results) map[id] = st;
|
||||||
|
setStatuses(map);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalWeight = effective.reduce((s, n) => s + n.weight, 0);
|
||||||
|
|
||||||
|
async function savePool(patch: { ollamaTimeoutS?: number; ollamaDefaultWeight?: number }) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await api.adminPatchSettings(patch);
|
||||||
|
setTimeoutS(String(res.settings.ollamaTimeoutS));
|
||||||
|
setDefWeight(String(res.settings.ollamaDefaultWeight));
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTimeout(raw: string) {
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isInteger(v) || v < 5 || v > 3600) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void savePool({ ollamaTimeoutS: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDefWeight(raw: string) {
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isInteger(v) || v < 0 || v > 1000) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void savePool({ ollamaDefaultWeight: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(node: OllamaNode, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
const res = await api.ollamaNodePatch(node.id, { enabled });
|
||||||
|
setNodes((prev) => prev.map((n) => (n.id === res.node.id ? res.node : n)));
|
||||||
|
const list = await api.ollamaNodes();
|
||||||
|
setEffective(list.effective);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
await api.ollamaNodeDelete(id);
|
||||||
|
setDeleteId(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: { id: string; name: string; host: string; weight: number; enabled: boolean; builtin: boolean }[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
name: "default",
|
||||||
|
host: statuses["default"]?.host ?? "…",
|
||||||
|
weight: effective.find((n) => n.id === "default")?.weight ?? 0,
|
||||||
|
enabled: effective.some((n) => n.id === "default"),
|
||||||
|
builtin: true,
|
||||||
|
},
|
||||||
|
...nodes.map((n) => ({ id: n.id, name: n.name, host: n.host, weight: n.weight, enabled: n.enabled, builtin: false })),
|
||||||
|
];
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Server size={14} className="opacity-60" /> {t("nodes.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 mt-0.5 text-sm opacity-60">{t("nodes.desc")}</p>
|
||||||
|
|
||||||
|
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<Field label={t("nodes.timeout")} hint={t("nodes.timeoutUnit")}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={3600}
|
||||||
|
value={timeoutS}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => setTimeoutS(e.target.value)}
|
||||||
|
onBlur={(e) => commitTimeout(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="mt-1 text-xs opacity-60">{t("nodes.timeoutDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<Field label={t("nodes.defWeight")} hint="0–1000">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
value={defWeight}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => setDefWeight(e.target.value)}
|
||||||
|
onBlur={(e) => commitDefWeight(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="mt-1 text-xs opacity-60">{t("nodes.defWeightDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const st = statuses[r.id] ?? null;
|
||||||
|
const online = st?.reachable === true;
|
||||||
|
const share = totalWeight > 0 && r.enabled && r.weight > 0 ? Math.round((r.weight / totalWeight) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800",
|
||||||
|
!r.enabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full", st === undefined ? "bg-stone-300 dark:bg-zinc-600" : online ? "bg-emerald-500" : "bg-red-500")} role="status" />
|
||||||
|
<span className="min-w-0 flex-1 basis-40">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{r.name}
|
||||||
|
{r.builtin && <span className="ml-2 rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">env</span>}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate font-mono text-xs opacity-50">{r.host}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-xs dark:bg-zinc-800" title={t("nodes.weightHint")}>
|
||||||
|
w:{r.weight} · {r.enabled ? t("nodes.share", { pct: share }) : t("nodes.off")}
|
||||||
|
</span>
|
||||||
|
{!r.builtin && (
|
||||||
|
<Switch
|
||||||
|
label={t("nodes.enabled")}
|
||||||
|
checked={r.enabled}
|
||||||
|
onChange={(v) => {
|
||||||
|
const full = nodes.find((n) => n.id === r.id);
|
||||||
|
if (full) void toggleEnabled(full, v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailId(r.id)}
|
||||||
|
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("nodes.detail")}
|
||||||
|
aria-label={t("nodes.detail")}
|
||||||
|
>
|
||||||
|
<Activity size={15} />
|
||||||
|
</button>
|
||||||
|
{!r.builtin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const full = nodes.find((n) => n.id === r.id);
|
||||||
|
if (full) setFormNode(full);
|
||||||
|
}}
|
||||||
|
className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"
|
||||||
|
title={t("nodes.edit")}
|
||||||
|
aria-label={t("nodes.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteId(r.id)}
|
||||||
|
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
|
title={t("common.delete")}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setFormNode("new")}>
|
||||||
|
<Plus size={14} /> {t("nodes.add")}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={() => refresh()}
|
||||||
|
className="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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NodeFormModal
|
||||||
|
node={formNode === "new" ? null : formNode}
|
||||||
|
open={formNode !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setFormNode(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detailId && <NodeDetailModal nodeId={detailId} onClose={() => setDetailId(null)} />}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId !== null}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
title={t("nodes.deleteTitle")}
|
||||||
|
message={t("nodes.deleteMsg")}
|
||||||
|
onConfirm={() => deleteId && remove(deleteId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- add / edit modal ---------- */
|
||||||
|
|
||||||
|
function NodeFormModal({ node, open, onClose }: { node: OllamaNode | null; open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [weight, setWeight] = useState("100");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [probe, setProbe] = useState<OllamaNodeStatus | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(node?.name ?? "");
|
||||||
|
setHost(node?.host ?? "");
|
||||||
|
setWeight(String(node?.weight ?? 100));
|
||||||
|
setEnabled(node?.enabled ?? true);
|
||||||
|
setProbe(null);
|
||||||
|
setError("");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [open, node]);
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
setProbing(true);
|
||||||
|
setProbe(null);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setProbe(await api.ollamaNodeProbe(host.trim()));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("nodes.testFail"));
|
||||||
|
} finally {
|
||||||
|
setProbing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const w = Number(weight);
|
||||||
|
const payload = { name: name.trim(), host: host.trim(), weight: w, enabled };
|
||||||
|
if (node) await api.ollamaNodePatch(node.id, payload);
|
||||||
|
else await api.ollamaNodeCreate(payload);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : t("nodes.invalid");
|
||||||
|
setError(/already registered/i.test(msg) ? t("nodes.dupHost") : msg);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={node ? t("nodes.edit") : t("nodes.add")} icon={Server}>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
<Field label={t("nodes.name")}>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("nodes.namePh")} required maxLength={60} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("nodes.host")} hint={t("nodes.hostHint")}>
|
||||||
|
<Input value={host} onChange={(e) => setHost(e.target.value)} placeholder={t("nodes.hostPh")} required inputMode="url" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="w-32">
|
||||||
|
<Field label={t("nodes.weight")} hint="0–1000">
|
||||||
|
<Input type="number" min={0} max={1000} value={weight} onChange={(e) => setWeight(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="pb-2.5">
|
||||||
|
<Switch label={t("nodes.enabled")} checked={enabled} onChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto pb-1">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={test} disabled={probing || !host.trim()}>
|
||||||
|
{probing ? <Spinner size={14} /> : <Activity size={14} />} {probing ? t("nodes.probing") : t("nodes.test")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{probe && (
|
||||||
|
<p className={cn("rounded-2xl px-4 py-2.5 text-sm", probe.reachable ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")} role="status">
|
||||||
|
{probe.reachable
|
||||||
|
? `${t("nodes.testOk")}${probe.version ? ` · v${probe.version}` : ""} · ${probe.models.length} models`
|
||||||
|
: `${t("nodes.testFail")}${probe.error ? ` — ${probe.error}` : ""}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs opacity-60">{t("nodes.weightHint")}</p>
|
||||||
|
{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" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={busy}>{busy ? <Spinner size={14} /> : t("common.save")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- node detail: live status + load charts ---------- */
|
||||||
|
|
||||||
|
function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => void }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [status, setStatus] = useState<OllamaNodeStatus | null>(null);
|
||||||
|
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
|
||||||
|
const [window, setWindow] = useState<StatsWindowId>("24h");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.ollamaNodeStatus(nodeId).then((s) => { if (alive) setStatus(s); }).catch(() => { if (alive) setStatus(null); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [nodeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.ollamaNodeStats(nodeId, window).then((r) => { if (alive) setStats(r.stats); }).catch(() => {});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [nodeId, window]);
|
||||||
|
|
||||||
|
const maxReq = Math.max(1, ...((stats?.buckets ?? []).map((b) => b.req)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={t("nodes.detail")} icon={Activity} wide>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => { onClose(); navigate("/admin/stats"); }}>
|
||||||
|
<Activity size={14} /> {t("stats.openFull")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!status ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("models.connHost")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm" title={status.host}>{status.host}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.version")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.version ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.latency")}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">{status.running.length} running</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{status.running.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.running")}</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{status.running.map((m) => (
|
||||||
|
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{t("nodes.vram")}: {fmtSize(m.sizeVram)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.stats")}</p>
|
||||||
|
<span className="ml-auto w-28">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("nodes.stats")}
|
||||||
|
value={window}
|
||||||
|
onChange={(v) => setWindow(v as StatsWindowId)}
|
||||||
|
align="right"
|
||||||
|
options={WINDOWS.map((w) => ({ value: w, label: w }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs opacity-80">
|
||||||
|
<span>{stats.totals.req} {t("nodes.req")}</span>
|
||||||
|
<span className={stats.totals.err > 0 ? "text-red-500" : undefined}>{stats.totals.err} {t("nodes.err")}</span>
|
||||||
|
<span>{t("nodes.avg")}: {stats.totals.avgMs === null ? "—" : `${Math.round(stats.totals.avgMs)} ms`}</span>
|
||||||
|
<span>{t("nodes.tps")}: {stats.totals.tokPerSec === null ? "—" : stats.totals.tokPerSec.toFixed(1)}</span>
|
||||||
|
<span>{stats.totals.promptTok + stats.totals.evalTok} {t("nodes.tok")}</span>
|
||||||
|
</div>
|
||||||
|
{stats.totals.req === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-4 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("nodes.emptyStats")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 items-end gap-[3px]" role="img" aria-label={`${stats.totals.req} ${t("nodes.req")}`}>
|
||||||
|
{stats.buckets.map((b, i) => {
|
||||||
|
const h = Math.max(2, Math.round((b.req / maxReq) * 100));
|
||||||
|
const errH = b.req > 0 ? Math.round(h * (b.err / b.req)) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex min-w-0 flex-1 flex-col justify-end overflow-hidden rounded-sm bg-accent-500/70"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
title={`${new Date(b.t).toLocaleString()} — ${b.req} ${t("nodes.req")}, ${b.err} ${t("nodes.err")}`}
|
||||||
|
>
|
||||||
|
{errH > 0 && <div className="w-full bg-red-500" style={{ height: `${(errH / h) * 100}%` }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.models.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("nodes.diskModels", { n: status.models.length })}</p>
|
||||||
|
<ul className="max-h-48 space-y-1.5 overflow-y-auto">
|
||||||
|
{status.models.map((m) => (
|
||||||
|
<li key={m.name} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{m.name}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{fmtSize(m.size)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs opacity-50">{t("nodes.cpuNote")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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} autoComplete="off" className="mt-4 space-y-2.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<Field label={t("common.username")}>
|
||||||
|
<Input
|
||||||
|
variant="soft"
|
||||||
|
name="ka-register-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="alice"
|
||||||
|
maxLength={32}
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("common.displayName")}>
|
||||||
|
<Input
|
||||||
|
variant="soft"
|
||||||
|
name="ka-register-displayname"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Alice"
|
||||||
|
maxLength={60}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
||||||
|
import { api, getToken, type Report, type ReportStatus } from "../lib/api.ts";
|
||||||
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.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({ onShadowbanChanged }: { onShadowbanChanged?: () => void }) {
|
||||||
|
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: the admin firehose pushes full report payloads, so rows
|
||||||
|
// update incrementally with zero HTTP (shares its socket with the
|
||||||
|
// AdminCenter badge via the pool). Full resync only on (re)connect.
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (evt.type === "report_created") {
|
||||||
|
setReports((prev) => (prev.some((r) => r.id === evt.report.id) ? prev : [evt.report, ...prev]));
|
||||||
|
} else if (evt.type === "report_status") {
|
||||||
|
setReports((prev) => prev.map((r) => (r.id === evt.report.id ? evt.report : r)));
|
||||||
|
}
|
||||||
|
// Access events: the AccessRequestsPanel owns them.
|
||||||
|
},
|
||||||
|
onSync: () => {
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 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}
|
||||||
|
onPatched={(updated) => setReports((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))}
|
||||||
|
onBanned={(reporterId, shadowbanned) => {
|
||||||
|
setReports((prev) =>
|
||||||
|
prev.map((x) => (x.reporter_id === reporterId ? { ...x, reporter_shadowbanned: shadowbanned ? 1 : 0 } : x)),
|
||||||
|
);
|
||||||
|
onShadowbanChanged?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDetail({ report, onPatched, onBanned }: { report: Report; onPatched: (r: Report) => void; onBanned: (reporterId: string, shadowbanned: boolean) => 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 {
|
||||||
|
// The report_status broadcast echoes the same payload to every admin —
|
||||||
|
// applying the response directly keeps this view instant and idempotent.
|
||||||
|
const res = await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
|
||||||
|
onPatched(res.report);
|
||||||
|
} 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);
|
||||||
|
// Shadow-ban has no WS event (per-user, not per-report): patch every
|
||||||
|
// row of this reporter locally and resync the badges over HTTP.
|
||||||
|
onBanned(report.reporter_id, shadowbanned);
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useEffect, useRef, 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 { Toasts } from "./Toasts.tsx";
|
||||||
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
|
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("");
|
||||||
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Message ids already applied (WS echo + HTTP response race): each lands once.
|
||||||
|
const seenIds = useRef(new Set<number>());
|
||||||
|
function appendMessage(msg: AccessMessage): boolean {
|
||||||
|
if (seenIds.current.has(msg.id)) return false;
|
||||||
|
seenIds.current.add(msg.id);
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow live traffic: stick to the bottom while the user already is.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = threadRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
|
||||||
|
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await api.accessTicket(ticketId);
|
||||||
|
for (const m of res.messages) seenIds.current.add(m.id);
|
||||||
|
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);
|
||||||
|
for (const m of res.messages) seenIds.current.add(m.id);
|
||||||
|
setRequest(res.request);
|
||||||
|
setMessages(res.messages);
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last known state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Live updates over WebSocket with full payloads: admin replies and the
|
||||||
|
// decision apply straight to state (no refetch); resync on (re)connect
|
||||||
|
// covers events missed while offline.
|
||||||
|
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Report events only travel the admin firehose — never ticket sockets.
|
||||||
|
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
|
||||||
|
if (evt.request_id !== ticketId) return;
|
||||||
|
if (evt.type === "access_message") {
|
||||||
|
if (appendMessage(evt.message) && evt.message.author === "admin") {
|
||||||
|
pushToast(t("live.adminReply"), { icon: "mail" });
|
||||||
|
}
|
||||||
|
} else if (evt.type === "access_status") {
|
||||||
|
setRequest(evt.request);
|
||||||
|
pushToast(t("live.ticketStatus", { status: t(`review.status.${evt.request.status}` as StringKey) }), { icon: "inbox" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
appendMessage(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 ref={threadRef} 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>
|
||||||
|
<Toasts />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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"}
|
||||||
|
{m.role !== "user" && m.model ? (
|
||||||
|
<span title={m.model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{m.model}</span>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { BarChart3, Cpu, Globe, HardDrive, Network, Server, Spline } from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type EffectiveOllamaNode,
|
||||||
|
type OllamaNodeStats,
|
||||||
|
type PlatformInfo,
|
||||||
|
} from "../lib/api.ts";
|
||||||
|
import { fmtSize } from "./OllamaNodes.tsx";
|
||||||
|
import { Picker, Spinner } from "./ui.tsx";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
|
type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
type ChartKind = "bar" | "area" | "line";
|
||||||
|
const CHARTS: { id: ChartKind; label: string; icon: typeof BarChart3 }[] = [
|
||||||
|
{ id: "bar", label: "Bar", icon: BarChart3 },
|
||||||
|
{ id: "area", label: "Area", icon: Spline },
|
||||||
|
{ id: "line", label: "Line", icon: Spline },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtUptime(totalS: number): string {
|
||||||
|
const d = Math.floor(totalS / 86400);
|
||||||
|
const h = Math.floor((totalS % 86400) / 3600);
|
||||||
|
const m = Math.floor((totalS % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClock(t: number): string {
|
||||||
|
const d = new Date(t);
|
||||||
|
const hh = d.getHours().toString().padStart(2, "0");
|
||||||
|
const mm = d.getMinutes().toString().padStart(2, "0");
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1} ${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin telemetry sub-page: pool load charts + platform host facts. */
|
||||||
|
export function StatsSection() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
|
||||||
|
const [nodeId, setNodeId] = useState("default");
|
||||||
|
const [window, setWindow] = useState<StatsWindowId>("24h");
|
||||||
|
const [kind, setKind] = useState<ChartKind>("bar");
|
||||||
|
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [platform, setPlatform] = useState<PlatformInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ollamaNodes()
|
||||||
|
.then((r) => setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]))
|
||||||
|
.catch(() => {});
|
||||||
|
api.platform().then((r) => setPlatform(r.platform)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.ollamaNodeStats(nodeId, window)
|
||||||
|
.then((r) => setStats(r.stats))
|
||||||
|
.catch(() => setStats(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [nodeId, window]);
|
||||||
|
|
||||||
|
const totals = stats?.totals;
|
||||||
|
|
||||||
|
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="mb-1 flex flex-wrap items-center gap-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Server size={14} className="opacity-60" /> {t("stats.poolTitle")}
|
||||||
|
</p>
|
||||||
|
<span className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-40">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("stats.node")}
|
||||||
|
value={nodeId}
|
||||||
|
onChange={setNodeId}
|
||||||
|
align="right"
|
||||||
|
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : n.name }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-24">
|
||||||
|
<Picker
|
||||||
|
ariaLabel={t("stats.window")}
|
||||||
|
value={window}
|
||||||
|
onChange={(v) => setWindow(v as StatsWindowId)}
|
||||||
|
align="right"
|
||||||
|
options={WINDOWS.map((w) => ({ value: w, label: w }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm opacity-60">{t("stats.poolDesc")}</p>
|
||||||
|
|
||||||
|
{/* Chart-type dock */}
|
||||||
|
<div className="mb-3 inline-flex gap-1 rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900" role="tablist" aria-label={t("stats.chart")}>
|
||||||
|
{CHARTS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === c.id}
|
||||||
|
onClick={() => setKind(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-medium transition",
|
||||||
|
kind === c.id
|
||||||
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
|
: "opacity-60 hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<c.icon size={13} />
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
|
||||||
|
{totals && (
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||||
|
<StatTile label={t("nodes.req")} value={String(totals.req)} />
|
||||||
|
<StatTile label={t("nodes.err")} value={String(totals.err)} alert={totals.err > 0} />
|
||||||
|
<StatTile label={t("nodes.avg")} value={totals.avgMs === null ? "—" : `${Math.round(totals.avgMs)} ms`} />
|
||||||
|
<StatTile label={t("nodes.tps")} value={totals.tokPerSec === null ? "—" : totals.tokPerSec.toFixed(1)} />
|
||||||
|
<StatTile label={t("nodes.tok")} value={String(totals.promptTok + totals.evalTok)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && (stats.totals.req === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
|
||||||
|
{t("nodes.emptyStats")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<LoadChart stats={stats} kind={kind} />
|
||||||
|
))}
|
||||||
|
<p className="mt-2 text-xs opacity-50">{t("nodes.cpuNote")}</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="mb-1 flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Cpu size={14} className="opacity-60" /> {t("stats.hostTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm opacity-60">{t("stats.hostDesc")}</p>
|
||||||
|
{!platform ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<StatTile label={t("stats.hostname")} value={platform.hostname} mono />
|
||||||
|
<StatTile label={t("stats.kernel")} value={platform.kernel} mono />
|
||||||
|
<StatTile label={t("stats.cpu")} value={`${platform.cpus} × ${platform.cpuModel?.split("@")[0]?.trim() ?? "?"}`} mono />
|
||||||
|
<StatTile label={t("stats.runtime")} value={platform.runtime} mono />
|
||||||
|
<StatTile label={t("stats.uptime")} value={fmtUptime(platform.uptimeS)} />
|
||||||
|
<StatTile label={t("stats.appUptime")} value={fmtUptime(platform.appUptimeS)} />
|
||||||
|
<StatTile label={t("stats.load")} value={platform.loadavg.map((v) => v.toFixed(2)).join(" ")} mono />
|
||||||
|
<StatTile
|
||||||
|
label={t("stats.memory")}
|
||||||
|
value={`${fmtSize(platform.memTotal - platform.memFree)} / ${fmtSize(platform.memTotal)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800" title={t("stats.memory")}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-600 dark:bg-accent-500"
|
||||||
|
style={{ width: `${Math.min(100, Math.round(((platform.memTotal - platform.memFree) / Math.max(1, platform.memTotal)) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider opacity-50">
|
||||||
|
<Network size={12} /> {t("stats.network")}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{platform.nets
|
||||||
|
.filter((n) => !n.address.startsWith("fe80"))
|
||||||
|
.sort((a, b) => Number(a.internal) - Number(b.internal))
|
||||||
|
.map((n, i) => (
|
||||||
|
<li key={`${n.name}-${n.address}-${i}`} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
|
||||||
|
<Globe size={13} className="shrink-0 opacity-50" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono">{n.address}</span>
|
||||||
|
<span className="shrink-0 text-xs opacity-60">{n.name} · {n.family}{n.internal ? " · lo" : ""}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs opacity-50">
|
||||||
|
<HardDrive size={12} /> {t("stats.diskNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value, mono, alert }: { label: string; value: string; mono?: boolean; alert?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<p className="truncate text-[11px] uppercase tracking-wider opacity-50">{label}</p>
|
||||||
|
<p className={cn("truncate text-sm font-medium", mono && "font-mono", alert && "text-red-500")} title={value}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SVG load chart with tooltip ---------- */
|
||||||
|
|
||||||
|
const W = 480;
|
||||||
|
const H = 180;
|
||||||
|
const PAD_L = 8;
|
||||||
|
const PAD_R = 8;
|
||||||
|
const PAD_T = 12;
|
||||||
|
const PAD_B = 22;
|
||||||
|
|
||||||
|
function LoadChart({ stats, kind }: { stats: OllamaNodeStats; kind: ChartKind }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
const buckets = stats.buckets;
|
||||||
|
const n = buckets.length;
|
||||||
|
const maxReq = Math.max(1, ...buckets.map((b) => b.req));
|
||||||
|
const maxAvg = Math.max(1, ...buckets.map((b) => b.avgMs ?? 0));
|
||||||
|
|
||||||
|
const iw = W - PAD_L - PAD_R;
|
||||||
|
const ih = H - PAD_T - PAD_B;
|
||||||
|
const step = iw / n;
|
||||||
|
const bw = Math.max(2, step - 2);
|
||||||
|
|
||||||
|
const { points, areaPath, errPoints } = useMemo(() => {
|
||||||
|
const pts = buckets.map((b, i) => ({
|
||||||
|
x: PAD_L + i * step + step / 2,
|
||||||
|
y: PAD_T + ih - (b.req / maxReq) * ih,
|
||||||
|
}));
|
||||||
|
const area = `M${PAD_L},${PAD_T + ih} ` + pts.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + ` L${(PAD_L + iw).toFixed(1)},${PAD_T + ih} Z`;
|
||||||
|
const err = buckets.map((b, i) => ({
|
||||||
|
x: PAD_L + i * step + step / 2,
|
||||||
|
y: PAD_T + ih - (b.err / maxReq) * ih,
|
||||||
|
}));
|
||||||
|
return { points: pts, areaPath: area, errPoints: err };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stats, kind]);
|
||||||
|
|
||||||
|
function onMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
const rect = wrapRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const sx = W / rect.width;
|
||||||
|
const x = (e.clientX - rect.left) * sx - PAD_L;
|
||||||
|
const i = Math.round(x / step - 0.5);
|
||||||
|
setHover(Math.max(0, Math.min(n - 1, i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hb = hover !== null ? buckets[hover]! : null;
|
||||||
|
// Latency sparkline (normalized, bottom-anchored, subtle).
|
||||||
|
const latPath = useMemo(() => {
|
||||||
|
const pts = buckets.map((b, i) => {
|
||||||
|
const x = PAD_L + i * step + step / 2;
|
||||||
|
const v = b.avgMs ?? 0;
|
||||||
|
return `L${x.toFixed(1)},${(PAD_T + ih - (v / maxAvg) * ih * 0.5).toFixed(1)}`;
|
||||||
|
});
|
||||||
|
return `M${PAD_L},${PAD_T + ih} ` + pts.join(" ");
|
||||||
|
}, [buckets, maxAvg, step, ih]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="relative">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
className="block w-full select-none"
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${stats.totals.req} ${t("nodes.req")}`}
|
||||||
|
>
|
||||||
|
{/* gridlines */}
|
||||||
|
{[0.25, 0.5, 0.75].map((f) => (
|
||||||
|
<line
|
||||||
|
key={f}
|
||||||
|
x1={PAD_L}
|
||||||
|
x2={W - PAD_R}
|
||||||
|
y1={PAD_T + ih * f}
|
||||||
|
y2={PAD_T + ih * f}
|
||||||
|
className="stroke-stone-200 dark:stroke-zinc-800"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{kind === "bar" && buckets.map((b, i) => {
|
||||||
|
const h = Math.max(1.5, (b.req / maxReq) * ih);
|
||||||
|
const x = PAD_L + i * step + (step - bw) / 2;
|
||||||
|
const y = PAD_T + ih - h;
|
||||||
|
const errH = b.req > 0 ? h * (b.err / b.req) : 0;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={x} y={y} width={bw} height={h} rx={1.5} style={{ fill: "rgb(var(--ka-accent-500) / 0.75)" }} opacity={hover === i ? 1 : 0.85} />
|
||||||
|
{errH > 0 && <rect x={x} y={y} width={bw} height={errH} rx={1.5} className="fill-red-500" />}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{kind === "area" && (
|
||||||
|
<>
|
||||||
|
<path d={areaPath} style={{ fill: "rgb(var(--ka-accent-500) / 0.25)" }} />
|
||||||
|
<polyline
|
||||||
|
points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
style={{ stroke: "rgb(var(--ka-accent-500))" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === "line" && (
|
||||||
|
<>
|
||||||
|
<polyline points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" style={{ stroke: "rgb(var(--ka-accent-500))" }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle key={i} cx={p.x} cy={p.y} r={hover === i ? 3.5 : 2} style={{ fill: "rgb(var(--ka-accent-500))" }} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* latency underlay */}
|
||||||
|
<path d={latPath} fill="none" className="stroke-sky-400" strokeWidth={1} opacity={0.5} />
|
||||||
|
{/* hover cursor */}
|
||||||
|
{hover !== null && (
|
||||||
|
<line x1={points[hover]!.x} x2={points[hover]!.x} y1={PAD_T} y2={PAD_T + ih} className="stroke-stone-400 dark:stroke-zinc-500" strokeWidth={1} strokeDasharray="2 2" />
|
||||||
|
)}
|
||||||
|
{/* x labels */}
|
||||||
|
{[0, Math.floor(n / 2), n - 1].map((i) => (
|
||||||
|
<text key={i} x={PAD_L + i * step + step / 2} y={H - 6} textAnchor="middle" fontSize={9} className="fill-stone-400 dark:fill-zinc-500">
|
||||||
|
{fmtClock(buckets[i]!.t)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{hb && hover !== null && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 min-w-36 rounded-2xl border border-stone-200 bg-white/95 px-3 py-2 text-xs shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95"
|
||||||
|
style={{
|
||||||
|
left: `clamp(4px, ${(points[hover]!.x / W) * 100}%, calc(100% - 150px))`,
|
||||||
|
top: 0,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-medium opacity-70">{fmtClock(hb.t)}</p>
|
||||||
|
<p>{hb.req} {t("nodes.req")} · <span className="text-red-500">{hb.err} {t("nodes.err")}</span></p>
|
||||||
|
<p className="opacity-70">{t("nodes.avg")}: {hb.avgMs === null ? "—" : `${Math.round(hb.avgMs)} ms`}</p>
|
||||||
|
<p className="opacity-70">{hb.promptTok + hb.evalTok} {t("nodes.tok")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-4 text-[11px] opacity-60">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full" style={{ background: "rgb(var(--ka-accent-500))" }} /> {t("nodes.req")}</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-red-500" /> {t("nodes.err")}</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-sky-400" /> {t("nodes.avg")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Archive, ArchiveRestore, Check, Cpu, Inbox, Mail, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "../lib/cn.ts";
|
||||||
|
import { removeToast, TOAST_DISPLAY_MS, useToasts, type Toast, type ToastIcon } from "../lib/toasts.ts";
|
||||||
|
|
||||||
|
const ICONS: Record<ToastIcon, LucideIcon> = {
|
||||||
|
trash: Trash2,
|
||||||
|
archive: Archive,
|
||||||
|
unarchive: ArchiveRestore,
|
||||||
|
model: Cpu,
|
||||||
|
theme: SunMoon,
|
||||||
|
accent: Palette,
|
||||||
|
unshare: Unplug,
|
||||||
|
edit: Pencil,
|
||||||
|
check: Check,
|
||||||
|
mail: Mail,
|
||||||
|
inbox: Inbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Must match the .toast-exit animation duration in index.css. */
|
||||||
|
const EXIT_MS = 260;
|
||||||
|
|
||||||
|
function ToastPill({ toast }: { toast: Toast }) {
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
// Full visibility window, then the exit animation. Restarted whenever a
|
||||||
|
// same-tag push replaces the text (nonce bump).
|
||||||
|
useEffect(() => {
|
||||||
|
setLeaving(false);
|
||||||
|
const t = window.setTimeout(() => setLeaving(true), TOAST_DISPLAY_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [toast.nonce]);
|
||||||
|
|
||||||
|
// Unmount once the exit animation has played out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leaving) return;
|
||||||
|
const t = window.setTimeout(() => removeToast(toast.id), EXIT_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [leaving, toast.id]);
|
||||||
|
|
||||||
|
const Icon = ICONS[toast.icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
onClick={() => setLeaving(true)}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex cursor-pointer items-center gap-2.5 rounded-full border border-stone-200 bg-white/95 py-2 pl-2.5 pr-4 shadow-xl backdrop-blur",
|
||||||
|
"dark:border-zinc-700 dark:bg-zinc-900/95",
|
||||||
|
leaving ? "toast-exit" : "toast-enter",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
|
||||||
|
<Icon size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="max-w-72 truncate text-sm font-medium">{toast.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-center pill stack. Fixed above the composer (bottom-24), over the
|
||||||
|
* modals (z-[70] > modal z-50) so settings actions toast visibly too.
|
||||||
|
*/
|
||||||
|
export function Toasts() {
|
||||||
|
const toasts = useToasts();
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-24 z-[70] flex flex-col items-center gap-2 px-4"
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastPill key={t.id} toast={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,693 @@
|
|||||||
|
import React, { useCallback, useEffect, useId, 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="shrink-0 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: a soft five-petal blossom with a lighter
|
||||||
|
* inner ring, petals following the runtime accent color (gradients read
|
||||||
|
* the --ka-accent-* triplets), heart in amber radial with a highlight —
|
||||||
|
* same artwork as the loading screen and favicon. Petals are deliberately
|
||||||
|
* slightly uneven (sizes/angles) so it reads organic, not geometric.
|
||||||
|
* `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 uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
const petalId = `ka-petal-${uid}`;
|
||||||
|
const petalInId = `ka-petalin-${uid}`;
|
||||||
|
const coreId = `ka-core-${uid}`;
|
||||||
|
const outer = [
|
||||||
|
{ r: 8, ry: 20 },
|
||||||
|
{ r: 80, ry: 19 },
|
||||||
|
{ r: 151, ry: 20.5 },
|
||||||
|
{ r: 223, ry: 19.5 },
|
||||||
|
{ r: 294, ry: 20 },
|
||||||
|
];
|
||||||
|
const inner = [
|
||||||
|
{ r: 44, ry: 12 },
|
||||||
|
{ r: 116, ry: 11.5 },
|
||||||
|
{ r: 188, ry: 12 },
|
||||||
|
{ r: 259, ry: 11.5 },
|
||||||
|
{ r: 331, ry: 12 },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
role="img"
|
||||||
|
aria-label="KisAssistant"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={petalId} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stopColor="rgb(var(--ka-accent-600))" />
|
||||||
|
<stop offset="0.6" stopColor="rgb(var(--ka-accent-500))" />
|
||||||
|
<stop offset="1" stopColor="rgb(var(--ka-accent-300))" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={petalInId} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stopColor="rgb(var(--ka-accent-400))" />
|
||||||
|
<stop offset="1" stopColor="rgb(var(--ka-accent-200))" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={coreId}>
|
||||||
|
<stop offset="0" stopColor="#fef3c7" />
|
||||||
|
<stop offset="0.5" stopColor="#fbbf24" />
|
||||||
|
<stop offset="1" stopColor="#d97706" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
{outer.map((p, i) => (
|
||||||
|
<g key={p.r} transform={`rotate(${p.r} 50 50)`}>
|
||||||
|
<ellipse
|
||||||
|
cx="50"
|
||||||
|
cy="27"
|
||||||
|
rx="13.5"
|
||||||
|
ry={p.ry}
|
||||||
|
fill={`url(#${petalId})`}
|
||||||
|
className={dynamic ? "flower-petal" : undefined}
|
||||||
|
style={dynamic ? { animationDelay: `${i * 0.2}s` } : undefined}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{inner.map((p, i) => (
|
||||||
|
<g key={p.r} transform={`rotate(${p.r} 50 50)`}>
|
||||||
|
<ellipse
|
||||||
|
cx="50"
|
||||||
|
cy="34"
|
||||||
|
rx="8"
|
||||||
|
ry={p.ry}
|
||||||
|
fill={`url(#${petalInId})`}
|
||||||
|
className={dynamic ? "flower-petal-inner" : undefined}
|
||||||
|
style={dynamic ? { animationDelay: `${i * 0.2 + 0.6}s` } : undefined}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<g className={dynamic ? "flower-core" : undefined}>
|
||||||
|
<circle cx="50" cy="50" r="10.5" fill="#f59e0b" opacity="0.22" />
|
||||||
|
<circle cx="50" cy="50" r="8" fill={`url(#${coreId})`} />
|
||||||
|
<circle cx="47.5" cy="47" r="2.2" fill="#fffbeb" opacity="0.95" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assistant identity: flower mark on a soft circle (message avatars).
|
||||||
|
* Pass `dynamic` while the assistant is thinking/streaming so the logo
|
||||||
|
* blooms, then back to static once the reply is done. */
|
||||||
|
export function AssistantAvatar({ size = 32, dynamic = false }: { size?: number; dynamic?: boolean }) {
|
||||||
|
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={dynamic} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clipboard write with legacy fallback (non-secure contexts lack the async API). */
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||||
|
const { t } = useT();
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copyText(text).then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
|
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="loader-title font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
|
||||||
|
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
@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;
|
||||||
|
--ka-accent-950: 2 44 34;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/* App-like viewport: pan + scroll only — no double-tap or pinch zoom.
|
||||||
|
(Kills the 300ms tap delay as a side effect, keeps gestures app-like.) */
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
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; }
|
||||||
|
/* Long unbroken strings (URLs, file paths, hashes, tokens) must wrap
|
||||||
|
anywhere — otherwise they overflow the bubble, which then grows past its
|
||||||
|
row and glues itself to the screen edge. `pre` keeps its own horizontal
|
||||||
|
scroll (white-space:pre ignores wrapping). */
|
||||||
|
.md {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.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: outer petals bloom in turn, inner petals follow softer,
|
||||||
|
core breathes with an amber glow. */
|
||||||
|
.flower-petal, .flower-petal-inner, .flower-core {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.flower-petal {
|
||||||
|
animation: bloom 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.flower-petal-inner {
|
||||||
|
animation: bloom-inner 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes bloom {
|
||||||
|
0%, 100% { transform: scale(0.62); opacity: 0.35; }
|
||||||
|
50% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes bloom-inner {
|
||||||
|
0%, 100% { transform: scale(0.55); opacity: 0.2; }
|
||||||
|
50% { transform: scale(1); opacity: 0.95; }
|
||||||
|
}
|
||||||
|
.flower-core {
|
||||||
|
animation: core-breathe 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes core-breathe {
|
||||||
|
0%, 100% { transform: scale(0.9); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loader title: soft accent glow pulse. */
|
||||||
|
.loader-title {
|
||||||
|
animation: title-glow 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes title-glow {
|
||||||
|
0%, 100% { opacity: 0.92; }
|
||||||
|
50% { opacity: 1; text-shadow: 0 0 18px rgb(var(--ka-accent-500) / 0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots::after {
|
||||||
|
content: "";
|
||||||
|
animation: dots 1.4s steps(4) infinite;
|
||||||
|
}
|
||||||
|
@keyframes dots {
|
||||||
|
0% { content: ""; }
|
||||||
|
25% { content: "."; }
|
||||||
|
50% { content: ".."; }
|
||||||
|
75% { content: "..."; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom pills: springy rise on appear, soft sink on dismiss. */
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(18px) scale(0.94); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.toast-enter {
|
||||||
|
animation: toast-in 0.38s cubic-bezier(0.21, 1.02, 0.73, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||||
|
}
|
||||||
|
.toast-exit {
|
||||||
|
animation: toast-out 0.26s ease-in both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* Brand flower intentionally exempt: it always animates, even when the
|
||||||
|
browser requests reduced motion (battery saver / eco mode). */
|
||||||
|
.typing-cursor::after,
|
||||||
|
.loading-dots::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice: Siri-style glow on the bubble being read aloud. */
|
||||||
|
.speaking-glow {
|
||||||
|
position: relative;
|
||||||
|
border-color: rgb(var(--ka-accent-500) / 0.55) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(var(--ka-accent-500) / 0.25),
|
||||||
|
0 0 22px rgb(var(--ka-accent-500) / 0.28),
|
||||||
|
0 8px 30px rgb(var(--ka-accent-500) / 0.12);
|
||||||
|
animation: speak-breathe 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes speak-breathe {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.25), 0 0 14px rgb(var(--ka-accent-500) / 0.2), 0 8px 30px rgb(var(--ka-accent-500) / 0.1); }
|
||||||
|
50% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.4), 0 0 30px rgb(var(--ka-accent-500) / 0.38), 0 8px 30px rgb(var(--ka-accent-500) / 0.16); }
|
||||||
|
}
|
||||||
|
.speaking-orb {
|
||||||
|
animation: orb-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes orb-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.55; transform: scale(0.86); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice recorder: pulsing REC dot, buttery bars, soft entrance. */
|
||||||
|
.rec-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 10px rgb(239 68 68 / 0.8);
|
||||||
|
animation: rec-blink 1.2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes rec-blink {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.45; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
.wave-bar {
|
||||||
|
transition: height 90ms linear, opacity 90ms linear;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
.voice-recorder {
|
||||||
|
animation: recorder-in 0.25s cubic-bezier(0.21, 1.02, 0.73, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes recorder-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
||||||
|
* custom picker; shades 50–950 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" | "950", 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", "950"] as const;
|
||||||
|
// Lightness ramps for 50→950 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, "950": 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HSV helpers for the custom color picker (no native input involved). */
|
||||||
|
export function hexToHsv(hex: string): { h: number; s: number; v: number } | null {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
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);
|
||||||
|
const d = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
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: max === 0 ? 0 : (d / max) * 100, v: max * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hsvToHex(h: number, s: number, v: number): string {
|
||||||
|
const sn = Math.min(100, Math.max(0, s)) / 100;
|
||||||
|
const vn = Math.min(100, Math.max(0, v)) / 100;
|
||||||
|
const hh = ((h % 360) + 360) % 360;
|
||||||
|
const c = vn * sn;
|
||||||
|
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
|
||||||
|
const m = vn - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (hh < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (hh < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (hh < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (hh < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (hh < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const to = (zv: number) => Math.round((zv + m) * 255).toString(16).padStart(2, "0");
|
||||||
|
return `#${to(r)}${to(g)}${to(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 50–950 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]);
|
||||||
|
paintFavicon(base);
|
||||||
|
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]);
|
||||||
|
paintFavicon(baseHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixHex(hex: string, target: number, amount: number): string {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
const n = m ? parseInt(m[1]!, 16) : 0x10b981;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
const mix = (v: number) => Math.round(v + (target - v) * amount);
|
||||||
|
const to = (v: number) => mix(v).toString(16).padStart(2, "0");
|
||||||
|
return `#${to(r)}${to(g)}${to(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favicon follows the accent AND the theme: the tile background is
|
||||||
|
* theme-owned (white by day, near-black by night) while the flower itself
|
||||||
|
* wears the accent ramp — never an accent tile.
|
||||||
|
*/
|
||||||
|
export function paintFavicon(baseHex: string, dark?: boolean): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
|
||||||
|
const isDark = dark ?? document.documentElement.classList.contains("dark");
|
||||||
|
const tile = isDark ? "#09090b" : "#ffffff";
|
||||||
|
const ring = isDark ? "#27272a" : "#e7e5e4";
|
||||||
|
const light = mixHex(base, 255, 0.55);
|
||||||
|
const petals = [8, 80, 151, 223, 295].map(
|
||||||
|
(a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`,
|
||||||
|
).join("");
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
|
||||||
|
`<rect width="100" height="100" rx="22" fill="${tile}" stroke="${ring}" stroke-width="3"/>${petals}` +
|
||||||
|
`<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` +
|
||||||
|
`<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`;
|
||||||
|
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
for (const rel of ["icon", "apple-touch-icon"]) {
|
||||||
|
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.rel = rel;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.type = rel === "icon" ? "image/svg+xml" : link.type;
|
||||||
|
link.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last accent base, so theme flips can repaint without knowing the color. */
|
||||||
|
let lastBase = "#10b981";
|
||||||
|
|
||||||
|
function repaint(): void {
|
||||||
|
paintFavicon(lastBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") {
|
||||||
|
new MutationObserver((muts) => {
|
||||||
|
for (const m of muts) {
|
||||||
|
if (m.attributeName === "class") {
|
||||||
|
repaint();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the saved accent on load.
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
applyAccent(currentAccent());
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type { AccessMessage, AccessRequest, Report } 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, and a
|
||||||
|
* pong watchdog reconnects half-dead sockets (ping answered by nobody)
|
||||||
|
* that would otherwise silently stop notifying.
|
||||||
|
*
|
||||||
|
* Events carry their full payload — handlers apply them straight to state.
|
||||||
|
* HTTP resync (onSync) is only the offline-gap fallback, never the
|
||||||
|
* per-event path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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; message: AccessMessage; message_count: number }
|
||||||
|
| { type: "report_created"; report: Report }
|
||||||
|
| { type: "report_status"; report: Report }
|
||||||
|
| { 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;
|
||||||
|
/** Last time the server proved liveness (open or pong). 0 = unknown. */
|
||||||
|
lastLive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalHooksInstalled = false;
|
||||||
|
|
||||||
|
const pools = new Map<string, Pool>();
|
||||||
|
|
||||||
|
/** Ping period; the watchdog tolerates one missed pong plus slack. */
|
||||||
|
const PING_MS = 25_000;
|
||||||
|
const WATCHDOG_MS = PING_MS + 10_000;
|
||||||
|
|
||||||
|
export function wsUrl(path: string): string {
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${window.location.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnect every pooled socket now (tab visible again / back online). */
|
||||||
|
function reconnectAll(): void {
|
||||||
|
for (const [url, pool] of pools) {
|
||||||
|
if (pool.listeners.size === 0) continue;
|
||||||
|
if (pool.retryTimer) {
|
||||||
|
clearTimeout(pool.retryTimer);
|
||||||
|
pool.retryTimer = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pool.ws?.close();
|
||||||
|
} catch {
|
||||||
|
// already gone — connect() below redials
|
||||||
|
}
|
||||||
|
pool.ws = null;
|
||||||
|
pool.backoff = 0;
|
||||||
|
connect(url, pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGlobalHooks(): void {
|
||||||
|
if (globalHooksInstalled || typeof document === "undefined") return;
|
||||||
|
globalHooksInstalled = true;
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") reconnectAll();
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => reconnectAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
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.lastLive = Date.now();
|
||||||
|
pool.pingTimer = setInterval(() => {
|
||||||
|
// Half-dead socket: OPEN but the server hasn't proved liveness for
|
||||||
|
// over a ping period + grace (proxy killed it, server restarted…).
|
||||||
|
// Close it so onclose schedules a fresh dial.
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
if (Date.now() - pool.lastLive > WATCHDOG_MS) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// close handler reconnects
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
} catch {
|
||||||
|
// send failed — the close handler will schedule a retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, PING_MS);
|
||||||
|
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;
|
||||||
|
// Any frame (event or pong) proves the socket is alive.
|
||||||
|
if (pool.ws === ws) pool.lastLive = Date.now();
|
||||||
|
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: Pool | undefined = pools.get(url);
|
||||||
|
if (!pool) {
|
||||||
|
const fresh: Pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null, lastLive: 0 };
|
||||||
|
pools.set(url, fresh);
|
||||||
|
pool = fresh;
|
||||||
|
installGlobalHooks();
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaHostModel {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoicePreview {
|
||||||
|
text: string;
|
||||||
|
lang: string;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
timbre: "masculine" | "feminine" | "any";
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInfo {
|
||||||
|
hostname: string;
|
||||||
|
kernel: string;
|
||||||
|
arch: string;
|
||||||
|
uptimeS: number;
|
||||||
|
appUptimeS: number;
|
||||||
|
loadavg: number[];
|
||||||
|
cpus: number;
|
||||||
|
cpuModel: string | null;
|
||||||
|
memTotal: number;
|
||||||
|
memFree: number;
|
||||||
|
runtime: string;
|
||||||
|
nets: { name: string; address: string; family: string; internal: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettings {
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
accessRequestEnabled: boolean;
|
||||||
|
ocrEnabled: boolean;
|
||||||
|
reportsEnabled: boolean;
|
||||||
|
usernameChangeEnabled: boolean;
|
||||||
|
ollamaTimeoutS: number;
|
||||||
|
ollamaDefaultWeight: number;
|
||||||
|
recoveryLimitMax: number;
|
||||||
|
recoveryLimitWindowMin: number;
|
||||||
|
voicePreviews: VoicePreview[];
|
||||||
|
ttsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettingsPatch {
|
||||||
|
registrationEnabled?: boolean;
|
||||||
|
accessRequestEnabled?: boolean;
|
||||||
|
ocrEnabled?: boolean;
|
||||||
|
reportsEnabled?: boolean;
|
||||||
|
usernameChangeEnabled?: boolean;
|
||||||
|
ollamaTimeoutS?: number;
|
||||||
|
ollamaDefaultWeight?: number;
|
||||||
|
recoveryLimitMax?: number;
|
||||||
|
recoveryLimitWindowMin?: number;
|
||||||
|
voicePreviews?: VoicePreview[];
|
||||||
|
ttsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveOllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNodeStatus {
|
||||||
|
reachable: boolean;
|
||||||
|
host: string;
|
||||||
|
version: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
models: OllamaHostModel[];
|
||||||
|
running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaStatsBucket {
|
||||||
|
t: number;
|
||||||
|
req: number;
|
||||||
|
err: number;
|
||||||
|
avgMs: number | null;
|
||||||
|
promptTok: number;
|
||||||
|
evalTok: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaNodeStats {
|
||||||
|
window: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
|
||||||
|
buckets: OllamaStatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
reachable: boolean;
|
||||||
|
version: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
models: OllamaHostModel[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: { username?: string; 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" }),
|
||||||
|
adminResetLink: (id: string) => req<{ ok: boolean; email: string }>(`/api/admin/users/${id}/reset-link`, { 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: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||||
|
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
|
adminGetSettings: () => req<{ settings: AdminSettings }>("/api/admin/settings"),
|
||||||
|
adminPatchSettings: (patch: AdminSettingsPatch) =>
|
||||||
|
req<{ settings: AdminSettings }>("/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,
|
||||||
|
opts: { nodeId?: string } = {},
|
||||||
|
): Promise<void> => {
|
||||||
|
const res = await fetch("/api/admin/ollama/pull", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify({ name, ...(opts.nodeId ? { nodeId: opts.nodeId } : {}) }),
|
||||||
|
});
|
||||||
|
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, nodeId?: string) =>
|
||||||
|
req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}${nodeId ? `?node=${encodeURIComponent(nodeId)}` : ""}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
/** Admin: Ollama node pool (extra hosts with firewall-like weights). */
|
||||||
|
ollamaNodes: () => req<{ nodes: OllamaNode[]; effective: EffectiveOllamaNode[] }>("/api/admin/ollama/nodes"),
|
||||||
|
ollamaNodeCreate: (n: { name: string; host: string; weight: number; enabled: boolean }) =>
|
||||||
|
req<{ node: OllamaNode }>("/api/admin/ollama/nodes", { method: "POST", body: JSON.stringify(n) }),
|
||||||
|
ollamaNodePatch: (id: string, patch: { name?: string; host?: string; weight?: number; enabled?: boolean }) =>
|
||||||
|
req<{ node: OllamaNode }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
ollamaNodeDelete: (id: string) =>
|
||||||
|
req<{ ok: boolean }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
|
ollamaNodeStatus: (id: string) =>
|
||||||
|
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
|
||||||
|
ollamaNodeProbe: (host: string) =>
|
||||||
|
req<OllamaNodeStatus>(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`),
|
||||||
|
/** Admin: platform host facts (hostname, kernel, uptime, network…). */
|
||||||
|
platform: () => req<{ platform: PlatformInfo }>("/api/admin/platform"),
|
||||||
|
ollamaNodeStats: (id: string, window: string) =>
|
||||||
|
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
|
||||||
|
|
||||||
|
/** Admin: Ollama connectivity probe (reachability, version, on-disk models). */
|
||||||
|
ollamaStatus: () => req<OllamaStatus>("/api/admin/ollama/status"),
|
||||||
|
|
||||||
|
/** 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 }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
|
||||||
|
|
||||||
|
/** Neural TTS provider status (null = browser speech fallback). */
|
||||||
|
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: { id: string; name: string }[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
|
||||||
|
|
||||||
|
/** Synthesize text server-side (neural voice). Returns audio bytes. */
|
||||||
|
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {
|
||||||
|
const res = await fetch("/api/tools/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
window.location.reload();
|
||||||
|
throw new ApiError(401, "Session expired.");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new ApiError(res.status, data.error || `TTS failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return await res.blob();
|
||||||
|
},
|
||||||
|
/** 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency-free syntax highlighting for chat code blocks.
|
||||||
|
*
|
||||||
|
* No highlight.js / prism (keeps the bundle small and the Nix offline build
|
||||||
|
* lockfile-stable): a small regex tokenizer per language family that emits
|
||||||
|
* <span> tokens colored for the always-dark <pre> bubble (see index.css).
|
||||||
|
* Unknown languages fall back to generic strings/comments/numbers so every
|
||||||
|
* block still gets some coloration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CLS = {
|
||||||
|
comment: "text-stone-400 italic",
|
||||||
|
string: "text-amber-200",
|
||||||
|
keyword: "text-violet-300",
|
||||||
|
number: "text-sky-300",
|
||||||
|
function: "text-emerald-300",
|
||||||
|
type: "text-rose-300",
|
||||||
|
decorator: "text-orange-300",
|
||||||
|
diffAdd: "text-emerald-300",
|
||||||
|
diffDel: "text-red-300",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TokKind = keyof typeof CLS | "plain";
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
kind: TokKind;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
mts: "typescript",
|
||||||
|
py: "python",
|
||||||
|
py3: "python",
|
||||||
|
sh: "bash",
|
||||||
|
shell: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
console: "bash",
|
||||||
|
terminal: "bash",
|
||||||
|
yml: "yaml",
|
||||||
|
json5: "json",
|
||||||
|
jsonc: "json",
|
||||||
|
cxx: "cpp",
|
||||||
|
"c++": "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
cs: "csharp",
|
||||||
|
rs: "rust",
|
||||||
|
golang: "go",
|
||||||
|
dockerfile: "docker",
|
||||||
|
makefile: "make",
|
||||||
|
md: "markdown",
|
||||||
|
html: "xml",
|
||||||
|
vue: "xml",
|
||||||
|
svelte: "xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYWORDS: Record<string, string[]> = {
|
||||||
|
javascript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set".split(" "),
|
||||||
|
typescript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set interface type enum implements private public protected readonly abstract namespace declare".split(" "),
|
||||||
|
python: "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case".split(" "),
|
||||||
|
bash: "if then else elif fi for while in do done case esac function select until echo exit return local export alias cd ls".split(" "),
|
||||||
|
rust: "as break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await dyn".split(" "),
|
||||||
|
go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var".split(" "),
|
||||||
|
java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while var".split(" "),
|
||||||
|
c: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while".split(" "),
|
||||||
|
cpp: "auto break case char const continue default do double else enum extern float for goto if inline int long namespace new private protected public return short signed sizeof static struct switch template this throw try typedef typename union unsigned using virtual void volatile while class constexpr decltype noexcept nullptr static_assert".split(" "),
|
||||||
|
csharp: "abstract as base bool break byte case catch char checked class const continue decimal default do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var".split(" "),
|
||||||
|
php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile extends final finally fn for foreach function global if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public require require_once return static switch throw trait try unset use var while xor yield".split(" "),
|
||||||
|
ruby: "alias and begin BEGIN break case class def defined do else elsif end END ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield".split(" "),
|
||||||
|
swift: "associatedtype class deinit enum extension fileprivate func if import in init inout internal let open operator private protocol public static struct subscript typealias var break case continue default defer do else fallthrough for guard repeat return switch where while as catch throw throws try await async".split(" "),
|
||||||
|
kotlin: "as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field finally get import init out set value where".split(" "),
|
||||||
|
sql: "select from where join left right inner outer on group by order having limit offset insert into values update set delete create table alter drop index view as and or not null primary key foreign references distinct union all exists in between like is asc desc".split(" "),
|
||||||
|
json: "true false null".split(" "),
|
||||||
|
yaml: "true false null yes no on off".split(" "),
|
||||||
|
css: "import media supports keyframes font-face".split(" "),
|
||||||
|
xml: [],
|
||||||
|
docker: "from run cmd label maintainer expose env add copy entrypoint volume user workdir arg onbuild stopsignal healthcheck shell".split(" "),
|
||||||
|
make: "ifeq ifneq else endif include override export unexport define endef".split(" "),
|
||||||
|
markdown: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function langKey(lang: string): string {
|
||||||
|
const l = lang.trim().toLowerCase();
|
||||||
|
return ALIASES[l] ?? l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(words: string[]): string {
|
||||||
|
return words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Grammar {
|
||||||
|
lineComment?: string;
|
||||||
|
blockComment?: boolean;
|
||||||
|
strings: ("sq" | "dq" | "bq" | "py3")[];
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function grammarFor(lang: string): Grammar | null {
|
||||||
|
switch (lang) {
|
||||||
|
case "javascript":
|
||||||
|
case "typescript":
|
||||||
|
return { lineComment: "//", blockComment: true, strings: ["sq", "dq", "bq"], keywords: KEYWORDS[lang]! };
|
||||||
|
case "json":
|
||||||
|
return { strings: ["dq"], keywords: KEYWORDS.json! };
|
||||||
|
case "python":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq", "py3"], keywords: KEYWORDS.python! };
|
||||||
|
case "bash":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.bash! };
|
||||||
|
case "rust":
|
||||||
|
case "go":
|
||||||
|
case "java":
|
||||||
|
case "c":
|
||||||
|
case "cpp":
|
||||||
|
case "csharp":
|
||||||
|
case "php":
|
||||||
|
case "swift":
|
||||||
|
case "kotlin":
|
||||||
|
return { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS[lang]! };
|
||||||
|
case "ruby":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.ruby! };
|
||||||
|
case "sql":
|
||||||
|
return { lineComment: "--", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.sql! };
|
||||||
|
case "yaml":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.yaml! };
|
||||||
|
case "css":
|
||||||
|
return { blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.css! };
|
||||||
|
case "xml":
|
||||||
|
return { strings: ["sq", "dq"], keywords: [] };
|
||||||
|
case "docker":
|
||||||
|
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.docker! };
|
||||||
|
case "make":
|
||||||
|
return { lineComment: "#", strings: [], keywords: KEYWORDS.make! };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING_RE: Record<string, string> = {
|
||||||
|
sq: "'(?:[^'\\\\\\n]|\\\\.)*'",
|
||||||
|
dq: '"(?:[^"\\\\\\n]|\\\\.)*"',
|
||||||
|
bq: "`(?:[^`\\\\]|\\\\.)*`",
|
||||||
|
py3: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tokenize(code: string, lang: string): Token[] {
|
||||||
|
const lk = langKey(lang);
|
||||||
|
|
||||||
|
// Diffs color whole lines — handled line by line, no grammar needed.
|
||||||
|
if (lk === "diff" || lk === "patch") {
|
||||||
|
return code.split("\n").flatMap((line, i, arr) => {
|
||||||
|
const kind: TokKind =
|
||||||
|
line.startsWith("+") && !line.startsWith("+++") ? "diffAdd" : line.startsWith("-") && !line.startsWith("---") ? "diffDel" : line.startsWith("@") ? "function" : "plain";
|
||||||
|
const toks: Token[] = [{ kind, text: line }];
|
||||||
|
if (i < arr.length - 1) toks.push({ kind: "plain", text: "\n" });
|
||||||
|
return toks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = grammarFor(lk);
|
||||||
|
// Unknown language: generic strings + comments + numbers.
|
||||||
|
const grammar: Grammar = g ?? { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: [] };
|
||||||
|
|
||||||
|
const commentAlts: string[] = [];
|
||||||
|
if (grammar.blockComment) commentAlts.push("/\\*[\\s\\S]*?(?:\\*/|$)");
|
||||||
|
if (lk === "xml") commentAlts.push("<!--[\\s\\S]*?(?:-->|$)");
|
||||||
|
if (grammar.lineComment) {
|
||||||
|
const lc = grammar.lineComment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// `//` must not match the `://` of URLs (https://…).
|
||||||
|
commentAlts.push(grammar.lineComment === "//" ? `(?<!:)//[^\\n]*` : `${lc}[^\\n]*`);
|
||||||
|
}
|
||||||
|
const stringAlts = grammar.strings.map((s) => STRING_RE[s]!);
|
||||||
|
|
||||||
|
const named: [TokKind, string][] = [];
|
||||||
|
if (commentAlts.length > 0) named.push(["comment", commentAlts.join("|")]);
|
||||||
|
if (stringAlts.length > 0) named.push(["string", stringAlts.join("|")]);
|
||||||
|
if (grammar.keywords.length > 0) named.push(["keyword", `\\b(?:${escapeRe(grammar.keywords)})\\b`]);
|
||||||
|
named.push(["number", "\\b(?:0x[0-9a-fA-F]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b"]);
|
||||||
|
named.push(["decorator", "@[A-Za-z_][\\w$]*"]);
|
||||||
|
named.push(["function", "[A-Za-z_][\\w$]*(?=\\s*\\()"]);
|
||||||
|
named.push(["type", "\\b[A-Z][A-Za-z0-9_]*\\b"]);
|
||||||
|
|
||||||
|
const re = new RegExp(named.map(([kind, p]) => `(?<${kind}>${p})`).join("|"), "g");
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
// Guard against pathological inputs: cap tokens, dump the rest as plain.
|
||||||
|
const MAX_TOKENS = 5000;
|
||||||
|
while ((m = re.exec(code)) !== null && tokens.length < MAX_TOKENS) {
|
||||||
|
if (m.index > last) tokens.push({ kind: "plain", text: code.slice(last, m.index) });
|
||||||
|
const text = m[0];
|
||||||
|
const groups = m.groups ?? {};
|
||||||
|
const kind: TokKind = (named.find(([k]) => groups[k] !== undefined)?.[0] ?? "plain") as TokKind;
|
||||||
|
tokens.push({ kind, text });
|
||||||
|
last = m.index + text.length;
|
||||||
|
if (text.length === 0) re.lastIndex++;
|
||||||
|
}
|
||||||
|
if (last < code.length) tokens.push({ kind: "plain", text: code.slice(last) });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightCode(code: string, lang: string): ReactNode[] {
|
||||||
|
const tokens = tokenize(code, lang);
|
||||||
|
return tokens.map((t, i) =>
|
||||||
|
t.kind === "plain" ? (
|
||||||
|
<span key={i}>{t.text}</span>
|
||||||
|
) : (
|
||||||
|
<span key={i} className={CLS[t.kind]}>
|
||||||
|
{t.text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Browser-local UI persistence (no server round-trip, per browser profile).
|
||||||
|
*
|
||||||
|
* - Selected model per account (`ka_model:<username>`): restored on boot when
|
||||||
|
* still offered, otherwise the first available model wins.
|
||||||
|
* - Failed model attempts (`ka_failed_v1`): the error bubble + retry state
|
||||||
|
* survive a refresh. The fake error text is NEVER stored server-side, so
|
||||||
|
* model history stays clean — the bubble is re-created locally on load and
|
||||||
|
* filtered out of every outgoing payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FailedEntry {
|
||||||
|
/** Position of the error bubble in the message list (trailing). */
|
||||||
|
index: number;
|
||||||
|
retryCount: number;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILED_KEY = "ka_failed_v1";
|
||||||
|
const FAILED_MAX_AGE_MS = 30 * 24 * 3600_000;
|
||||||
|
|
||||||
|
function readFailedMap(): Record<string, FailedEntry> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FAILED_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const out: Record<string, FailedEntry> = {};
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of Object.entries(obj ?? {})) {
|
||||||
|
const e = v as FailedEntry;
|
||||||
|
if (
|
||||||
|
v !== null &&
|
||||||
|
typeof v === "object" &&
|
||||||
|
Number.isInteger(e.index) &&
|
||||||
|
e.index >= 0 &&
|
||||||
|
typeof e.retryCount === "number" &&
|
||||||
|
typeof e.at === "number" &&
|
||||||
|
now - e.at < FAILED_MAX_AGE_MS
|
||||||
|
) {
|
||||||
|
out[k] = { index: e.index, retryCount: e.retryCount, at: e.at };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFailedMap(map: Record<string, FailedEntry>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FAILED_KEY, JSON.stringify(map));
|
||||||
|
} catch {
|
||||||
|
// Private mode / quota — retry simply won't survive a refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFailedEntry(convId: string): FailedEntry | null {
|
||||||
|
return readFailedMap()[convId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFailedEntry(convId: string, entry: FailedEntry): void {
|
||||||
|
const map = readFailedMap();
|
||||||
|
map[convId] = entry;
|
||||||
|
writeFailedMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFailedEntry(convId: string): void {
|
||||||
|
const map = readFailedMap();
|
||||||
|
if (map[convId] !== undefined) {
|
||||||
|
delete map[convId];
|
||||||
|
writeFailedMap(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelKey(username: string): string {
|
||||||
|
return `ka_model:${username.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedModel(username: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(modelKey(username));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSavedModel(username: string, id: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(modelKey(username), id);
|
||||||
|
} catch {
|
||||||
|
// ignore — falls back to first model next boot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved pick when still offered, otherwise the first available model. */
|
||||||
|
export function pickModel(available: { id: string }[], saved: string | null): string {
|
||||||
|
if (saved && available.some((m) => m.id === saved)) return saved;
|
||||||
|
return available[0]?.id ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /chat itself or one conversation page (/chat/<id>). */
|
||||||
|
export function isChatPath(pathname: string): boolean {
|
||||||
|
const p = normalizePath(pathname);
|
||||||
|
return p === "/chat" || p.startsWith("/chat/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversation id from /chat/<id>, or null on /chat itself. */
|
||||||
|
export function chatIdFromPath(pathname: string): string | null {
|
||||||
|
const m = normalizePath(pathname).match(/^\/chat\/([^/]+)$/);
|
||||||
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin section from /admin itself (null) or /admin/<tab>. */
|
||||||
|
export function adminTabFromPath(pathname: string): string | null {
|
||||||
|
const p = normalizePath(pathname);
|
||||||
|
if (p === "/admin") return null;
|
||||||
|
const m = p.match(/^\/admin\/([^/]+)$/);
|
||||||
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* "Sunset" theme: follows the browser's local time instead of the OS setting.
|
||||||
|
* Day (07:00–19:00 local) -> light, night -> dark. No geolocation needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SOLAR_DAY_START_HOUR = 7;
|
||||||
|
export const SOLAR_NIGHT_START_HOUR = 19;
|
||||||
|
|
||||||
|
/** True when the given local time falls in the night range (dark theme). */
|
||||||
|
export function isSolarDark(now: Date = new Date()): boolean {
|
||||||
|
const h = now.getHours();
|
||||||
|
return h < SOLAR_DAY_START_HOUR || h >= SOLAR_NIGHT_START_HOUR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Milliseconds from `now` until the next 07:00 / 19:00 local boundary. */
|
||||||
|
export function msUntilNextSolarSwitch(now: Date = new Date()): number {
|
||||||
|
const next = new Date(now);
|
||||||
|
const h = now.getHours();
|
||||||
|
if (h < SOLAR_DAY_START_HOUR) {
|
||||||
|
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||||
|
} else if (h < SOLAR_NIGHT_START_HOUR) {
|
||||||
|
next.setHours(SOLAR_NIGHT_START_HOUR, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0);
|
||||||
|
}
|
||||||
|
return Math.max(1000, next.getTime() - now.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a stored theme value to dark/light. `sunset` uses local time. */
|
||||||
|
export function resolveThemeDark(theme: string, now: Date = new Date()): boolean {
|
||||||
|
if (theme === "dark") return true;
|
||||||
|
if (theme === "light") return false;
|
||||||
|
if (theme === "sunset") return isSolarDark(now);
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia) {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-pill notifications (toasts): one-line confirmations when the user
|
||||||
|
* takes an action (chat deleted/archived, model/theme/accent changed…).
|
||||||
|
* Same subscribe pattern as features.ts / i18n.ts — callable from anywhere
|
||||||
|
* (App, settings sections) without prop drilling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ToastIcon =
|
||||||
|
| "trash"
|
||||||
|
| "archive"
|
||||||
|
| "unarchive"
|
||||||
|
| "model"
|
||||||
|
| "theme"
|
||||||
|
| "accent"
|
||||||
|
| "unshare"
|
||||||
|
| "edit"
|
||||||
|
| "check"
|
||||||
|
| "mail"
|
||||||
|
| "inbox";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
icon: ToastIcon;
|
||||||
|
/** Same-tag pushes replace the pill instead of stacking (accent, model). */
|
||||||
|
tag: string | null;
|
||||||
|
/** Bumped on replace so the pill restarts its enter/dismiss timers. */
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a pill stays fully visible before its exit animation. */
|
||||||
|
export const TOAST_DISPLAY_MS = 3000;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
let toasts: Toast[] = [];
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushToast(text: string, opts: { icon?: ToastIcon; tag?: string } = {}): void {
|
||||||
|
const icon = opts.icon ?? "check";
|
||||||
|
const tag = opts.tag ?? null;
|
||||||
|
if (tag) {
|
||||||
|
const existing = toasts.find((t) => t.tag === tag);
|
||||||
|
if (existing) {
|
||||||
|
toasts = toasts.map((t) =>
|
||||||
|
t.id === existing.id ? { ...t, text, icon, nonce: t.nonce + 1 } : t,
|
||||||
|
);
|
||||||
|
emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cap the stack so rapid actions never flood the bottom of the screen.
|
||||||
|
toasts = [...toasts.slice(-2), { id: nextId++, text, icon, tag, nonce: 0 }];
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToast(id: number): void {
|
||||||
|
if (!toasts.some((t) => t.id === id)) return;
|
||||||
|
toasts = toasts.filter((t) => t.id !== id);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToasts(): Toast[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => toasts,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
label: string;
|
||||||
|
group?: string;
|
||||||
|
/** Served through your own provider key (BYOK) — shown in its own menu section. */
|
||||||
|
personal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Voice: speech-to-text (composer) + text-to-speech (message playback).
|
||||||
|
*
|
||||||
|
* STT runs on the browser SpeechRecognition API when available
|
||||||
|
* (Chrome/Edge); otherwise the mic is disabled with an explanatory tooltip.
|
||||||
|
* TTS runs on speechSynthesis with per-user prefs (voice, rate, pitch,
|
||||||
|
* language incl. auto-detect). Server-side whisper/piper binaries are the
|
||||||
|
* documented future path (see docs) — no silent stub is shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VoicePrefs {
|
||||||
|
/** SpeechSynthesis voiceURI, or null = best match for the language. */
|
||||||
|
voiceURI: string | null;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
/** "auto" detects per message, otherwise forces a BCP-47 prefix. */
|
||||||
|
lang: string;
|
||||||
|
/** Playback engine: neural server TTS when available, else browser. */
|
||||||
|
engine: "auto" | "browser" | "server";
|
||||||
|
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). */
|
||||||
|
serverVoice: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFS_KEY = "ka:voice";
|
||||||
|
|
||||||
|
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto", engine: "auto", serverVoice: null };
|
||||||
|
|
||||||
|
export function getVoicePrefs(): VoicePrefs {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "null") as Partial<VoicePrefs> | null;
|
||||||
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_VOICE_PREFS };
|
||||||
|
const engine = raw.engine === "browser" || raw.engine === "server" ? raw.engine : "auto";
|
||||||
|
return {
|
||||||
|
voiceURI: typeof raw.voiceURI === "string" ? raw.voiceURI : null,
|
||||||
|
rate: typeof raw.rate === "number" && raw.rate >= 0.5 && raw.rate <= 2 ? raw.rate : 1,
|
||||||
|
pitch: typeof raw.pitch === "number" && raw.pitch >= 0.5 && raw.pitch <= 2 ? raw.pitch : 1,
|
||||||
|
lang: typeof raw.lang === "string" && raw.lang.length <= 12 ? raw.lang : "auto",
|
||||||
|
engine,
|
||||||
|
serverVoice: typeof raw.serverVoice === "string" && raw.serverVoice.length > 0 ? raw.serverVoice : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_VOICE_PREFS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVoicePrefs(p: VoicePrefs): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max chars sent to any TTS engine (server enforces the same cap). */
|
||||||
|
export const SPEECH_MAX_CHARS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown → clean speakable text. Code blocks become a pause (reading
|
||||||
|
* code aloud is noise), formatting/links/URLs are stripped to their
|
||||||
|
* readable part, paragraphs become pauses. Over-long texts are cut at a
|
||||||
|
* sentence boundary so playback never stops mid-word.
|
||||||
|
*/
|
||||||
|
export function toSpeechText(md: string): string {
|
||||||
|
let s = md;
|
||||||
|
s = s.replace(/```[\s\S]*?```/g, ". ");
|
||||||
|
s = s.replace(/`([^`]+)`/g, "$1");
|
||||||
|
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||||
|
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||||
|
s = s.replace(/^#{1,6}\s+/gm, "");
|
||||||
|
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
||||||
|
s = s.replace(/(^|[\s(])[*_]([^*_\n]+)[*_]/g, "$1$2");
|
||||||
|
s = s.replace(/^>\s?/gm, "");
|
||||||
|
s = s.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "");
|
||||||
|
s = s.replace(/^(\*\*\*|---|___)\s*$/gm, "");
|
||||||
|
s = s.replace(/<[^>]+>/g, " ");
|
||||||
|
s = s.replace(/https?:\/\/[^\s)]+/g, " ");
|
||||||
|
s = s.replace(/[ \t]+/g, " ");
|
||||||
|
s = s.replace(/\n{2,}/g, ". ");
|
||||||
|
s = s.replace(/\n/g, " ");
|
||||||
|
s = s.replace(/\s{2,}/g, " ").trim();
|
||||||
|
if (s.length > SPEECH_MAX_CHARS) {
|
||||||
|
const cut = s.slice(0, SPEECH_MAX_CHARS);
|
||||||
|
const m = cut.match(/.*[.!?…]["»\s]/);
|
||||||
|
s = (m ? m[0] : cut).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naive per-message language guess (accents + stop-words). */export function detectLang(text: string): string {
|
||||||
|
const t = ` ${text.toLowerCase()} `;
|
||||||
|
if (/[àâäéèêëîïôöùûüÿçœæ]/.test(text)) return "fr";
|
||||||
|
const fr = [" le ", " la ", " les ", " un ", " une ", " des ", " est ", " sont ", " que ", " qui ", " pour ", " dans ", " avec ", " je ", " tu ", " vous ", " pas ", " plus ", " cette ", " comme "];
|
||||||
|
const en = [" the ", " and ", " is ", " are ", " you ", " that ", " with ", " for ", " not ", " this ", " have ", " from "];
|
||||||
|
let fs = 0;
|
||||||
|
for (const w of fr) if (t.includes(w)) fs++;
|
||||||
|
let es = 0;
|
||||||
|
for (const w of en) if (t.includes(w)) es++;
|
||||||
|
if (fs === 0 && es === 0) return "en";
|
||||||
|
return fs >= es ? "fr" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ttsSupported(): boolean {
|
||||||
|
return typeof window !== "undefined" && "speechSynthesis" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTtsVoices(): Promise<SpeechSynthesisVoice[]> {
|
||||||
|
if (!ttsSupported()) return Promise.resolve([]);
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
const snapshot = synth.getVoices();
|
||||||
|
if (snapshot.length > 0) return Promise.resolve(snapshot);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const done = () => resolve(synth.getVoices());
|
||||||
|
synth.addEventListener("voiceschanged", done, { once: true });
|
||||||
|
// Some engines never fire the event after getVoices polling — bail out.
|
||||||
|
setTimeout(done, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakOpts {
|
||||||
|
onstart?: () => void;
|
||||||
|
onend?: () => void;
|
||||||
|
onerror?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceTimbre = "masculine" | "feminine" | "any";
|
||||||
|
|
||||||
|
/** Stock OpenAI voices (static catalog — selectable even before any key). */
|
||||||
|
export const OPENAI_STATIC_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||||
|
|
||||||
|
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
|
||||||
|
// Safari and Android voices carry such markers; absence falls back to
|
||||||
|
// any same-language voice — never an error).
|
||||||
|
const MASCULINE_HINTS = ["male", "homme", "masculin", "man", "garcon", "garçon", "david", "daniel", "thomas", "paul", "pierre", "jean", "marco", "jorge", "diego", "alex "];
|
||||||
|
const FEMININE_HINTS = ["female", "femme", "féminin", "feminin", "woman", "girl", "fille", "samantha", "marie", "sophie", "anna", "alice", "amelie", "amélie", "camille", "léa", "lea", "chloe", "chloé", "zira", "eva"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a voice for a language + timbre character. Explicit URI wins, then
|
||||||
|
* same-language voices matching the timbre hints, then any same-language
|
||||||
|
* voice, then the engine default.
|
||||||
|
*/
|
||||||
|
export function pickVoice(
|
||||||
|
voices: SpeechSynthesisVoice[],
|
||||||
|
opts: { lang: string; timbre?: VoiceTimbre; voiceURI?: string | null },
|
||||||
|
): SpeechSynthesisVoice | null {
|
||||||
|
if (opts.voiceURI) {
|
||||||
|
const exact = voices.find((v) => v.voiceURI === opts.voiceURI);
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
const lang = opts.lang.toLowerCase();
|
||||||
|
const sameLang = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
|
||||||
|
const pool = sameLang.length > 0 ? sameLang : voices;
|
||||||
|
if (opts.timbre === "masculine" || opts.timbre === "feminine") {
|
||||||
|
const hints = opts.timbre === "masculine" ? MASCULINE_HINTS : FEMININE_HINTS;
|
||||||
|
const match = pool.find((v) => {
|
||||||
|
const n = `${v.name} ${v.voiceURI}`.toLowerCase();
|
||||||
|
return hints.some((h) => n.includes(h));
|
||||||
|
});
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return pool.find((v) => v.default) ?? pool[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speak one message; cancels any current playback first. Returns false when unsupported. */
|
||||||
|
export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean {
|
||||||
|
if (!ttsSupported()) return false;
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
try {
|
||||||
|
synth.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const lang = prefs.lang === "auto" ? detectLang(text) : prefs.lang;
|
||||||
|
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
|
||||||
|
utter.lang = lang;
|
||||||
|
utter.rate = prefs.rate;
|
||||||
|
utter.pitch = prefs.pitch;
|
||||||
|
const pick = pickVoice(voices, { lang, voiceURI: prefs.voiceURI });
|
||||||
|
if (pick) utter.voice = pick;
|
||||||
|
utter.onstart = () => opts.onstart?.();
|
||||||
|
utter.onend = () => opts.onend?.();
|
||||||
|
utter.onerror = (e) => opts.onerror?.(e.error || "error");
|
||||||
|
try {
|
||||||
|
synth.speak(utter);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSpeak(): void {
|
||||||
|
try {
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- unified playback (neural server audio with browser fallback) ---
|
||||||
|
|
||||||
|
export interface PlayHandle {
|
||||||
|
/** Engine that ended up playing (may differ from the request in auto). */
|
||||||
|
engine: "server" | "browser";
|
||||||
|
stop: () => void;
|
||||||
|
done: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentAudio: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
function stopCurrentAudio(): void {
|
||||||
|
if (currentAudio) {
|
||||||
|
try {
|
||||||
|
currentAudio.pause();
|
||||||
|
const url = currentAudio.src;
|
||||||
|
currentAudio.removeAttribute("src");
|
||||||
|
currentAudio.load();
|
||||||
|
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop whatever is playing (server audio or browser speech). */
|
||||||
|
export function stopPlayback(): void {
|
||||||
|
stopCurrentAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayRequest {
|
||||||
|
text: string;
|
||||||
|
/** "server" forces neural, "browser" forces synthesis, "auto" tries neural first. */
|
||||||
|
engine: "auto" | "browser" | "server";
|
||||||
|
browser: {
|
||||||
|
voices: SpeechSynthesisVoice[];
|
||||||
|
/** "auto" detects per text, otherwise a BCP-47 prefix. */
|
||||||
|
lang: string;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
/** Explicit voice URI (user pref); otherwise timbre-matched. */
|
||||||
|
voiceURI?: string | null;
|
||||||
|
/** Preview character timbre (ignored when voiceURI is set). */
|
||||||
|
timbre?: VoiceTimbre;
|
||||||
|
};
|
||||||
|
/** Server neural voice id (preview line voice or user serverVoice). */
|
||||||
|
serverVoice?: string | null;
|
||||||
|
/** True when the platform TTS tool is available. */
|
||||||
|
serverAvailable: boolean;
|
||||||
|
fetchAudio: (text: string, voice?: string) => Promise<Blob>;
|
||||||
|
onstart?: () => void;
|
||||||
|
onend?: () => void;
|
||||||
|
onerror?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play one text with the best engine: neural server audio when requested
|
||||||
|
* and available (shared <audio> element, user speed), otherwise the
|
||||||
|
* browser character voice. Single global playback — a new play stops the
|
||||||
|
* previous one. `done` resolves when playback fully ends (fallback
|
||||||
|
* included); it never rejects (errors go to onend/onerror like speech).
|
||||||
|
*/
|
||||||
|
export function playText(req: PlayRequest): PlayHandle {
|
||||||
|
stopPlayback();
|
||||||
|
// Never read markdown cruft aloud; fall back to raw text when cleaning
|
||||||
|
// leaves nothing (e.g. a code-only message).
|
||||||
|
const clean = toSpeechText(req.text) || req.text.slice(0, SPEECH_MAX_CHARS);
|
||||||
|
const cleanReq: PlayRequest = { ...req, text: clean };
|
||||||
|
const wantServer = cleanReq.serverAvailable && cleanReq.engine !== "browser";
|
||||||
|
if (wantServer) return playServer(cleanReq, cleanReq.engine === "auto");
|
||||||
|
return playBrowser(cleanReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playServer(req: PlayRequest, allowFallback: boolean): PlayHandle {
|
||||||
|
let settled = false;
|
||||||
|
const stop = () => {
|
||||||
|
settled = true;
|
||||||
|
stopCurrentAudio();
|
||||||
|
};
|
||||||
|
const done = (async () => {
|
||||||
|
try {
|
||||||
|
const blob = await req.fetchAudio(req.text.slice(0, 2000), req.serverVoice ?? undefined);
|
||||||
|
if (settled) return;
|
||||||
|
await playAudioBlob(blob, req, () => settled);
|
||||||
|
} catch (e) {
|
||||||
|
if (settled) return;
|
||||||
|
if (allowFallback) {
|
||||||
|
await playBrowserAudio(req, () => settled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.onerror?.(e instanceof Error ? e.message : "playback failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return { engine: "server", stop, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAudioBlob(blob: Blob, req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.playbackRate = req.browser.rate;
|
||||||
|
currentAudio = audio;
|
||||||
|
const finish = (ok: boolean) => {
|
||||||
|
if (currentAudio === audio) stopCurrentAudio();
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled() && ok) req.onend?.();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
audio.onended = () => finish(true);
|
||||||
|
audio.onerror = () => finish(false);
|
||||||
|
req.onstart?.();
|
||||||
|
audio.play().catch(() => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playBrowser(req: PlayRequest): PlayHandle {
|
||||||
|
let settled = false;
|
||||||
|
const stop = () => {
|
||||||
|
settled = true;
|
||||||
|
stopSpeak();
|
||||||
|
};
|
||||||
|
const done = playBrowserAudio(req, () => settled);
|
||||||
|
return { engine: "browser", stop, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
function playBrowserAudio(req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const b = req.browser;
|
||||||
|
const lang = b.lang === "auto" ? detectLang(req.text) : b.lang;
|
||||||
|
const explicit = b.voiceURI ? b.voices.find((v) => v.voiceURI === b.voiceURI) : undefined;
|
||||||
|
const pick = explicit ?? pickVoice(b.voices, { lang, timbre: b.timbre ?? "any" });
|
||||||
|
const finish = (ok: boolean, msg?: string) => {
|
||||||
|
if (cancelled()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ok) req.onend?.();
|
||||||
|
else req.onerror?.(msg ?? "error");
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const ok = speak(
|
||||||
|
req.text,
|
||||||
|
{ voiceURI: pick?.voiceURI ?? null, rate: b.rate, pitch: b.pitch, lang, engine: "browser", serverVoice: null },
|
||||||
|
b.voices,
|
||||||
|
{ onstart: () => req.onstart?.(), onend: () => finish(true), onerror: (m) => finish(false, m) },
|
||||||
|
);
|
||||||
|
if (!ok) finish(false, "unsupported");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recognizer {
|
||||||
|
lang: string;
|
||||||
|
interimResults: boolean;
|
||||||
|
continuous: boolean;
|
||||||
|
onresult: ((ev: { results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
|
||||||
|
onerror: ((ev: { error?: string }) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
abort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecognizerCtor = new () => Recognizer;
|
||||||
|
|
||||||
|
export function recognizerCtor(): RecognizerCtor | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const w = window as unknown as Record<string, unknown>;
|
||||||
|
return ((w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as RecognizerCtor | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sttSupported(): boolean {
|
||||||
|
return recognizerCtor() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import "@fontsource-variable/inter";
|
||||||
|
import "./index.css";
|
||||||
|
import { App } from "./App.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock page zoom app-wide (viewport meta + CSS touch-action cover most
|
||||||
|
* browsers; these listeners catch the rest):
|
||||||
|
* - iOS Safari ignores `user-scalable=no`, so `gesturestart` is cancelled.
|
||||||
|
* - Desktop: Ctrl+wheel (incl. trackpad pinch) and Ctrl + +/-/0 are blocked.
|
||||||
|
*/
|
||||||
|
function useZoomLock() {
|
||||||
|
useEffect(() => {
|
||||||
|
const stop = (e: Event) => e.preventDefault();
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey) e.preventDefault();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && (e.key === "+" || e.key === "-" || e.key === "=" || e.key === "0")) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("gesturestart", stop);
|
||||||
|
document.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("gesturestart", stop);
|
||||||
|
document.removeEventListener("wheel", onWheel);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
useZoomLock();
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(<Root />);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** @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>)",
|
||||||
|
950: "rgb(var(--ka-accent-950) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
{ 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"; };
|
||||||
|
postgresqlUrl = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Postgres connection URL (e.g. postgres://user:pass@localhost:5432/kisassistant,
|
||||||
|
typically a docker container). Null (default) keeps the local SQLite file.
|
||||||
|
Prefer a credential file + extraEnv for secrets in production.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
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.postgresqlUrl != null) {
|
||||||
|
POSTGRESQL_URL = cfg.postgresqlUrl;
|
||||||
|
} // 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;";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
Il y as plusieurs bug d'UX bien chiant:
|
||||||
|
Quand un modèle as une erreur: Le modèle le dit, je refresh, le message d'errur à disparue -> Rendre ces message perisstant
|
||||||
|
Quand j'envoie un message dans une conversation , l'IA think: je vais sur une autre, sont overly aussi overide une autre conversation: bien segmenter sa
|
||||||
|
Le modèles choisis n'est pas sauvegarder, rend l'user choix perssistant
|
||||||
|
|
||||||
|
|
||||||
|
____
|
||||||
|
|
||||||
|
Pouvoir ajouter d'autre node ollama dans l'admin center. modale, composant, gestion de priorité avec des "poids" comme un firewall
|
||||||
|
Gestions des rate-limites pour recovery password...
|
||||||
|
admin center: gérer les url pages
|
||||||
|
|
||||||
|
exemple: ouverture catégorie modèles: <host>/admin/models
|
||||||
|
fait un timeout sur les requete envoyez au ollama , configurable dans l'admin center.
|
||||||
|
Faire un catalog des modèles ollama via internet /hugging face compatible ollama.
|
||||||
|
voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m))
|
||||||
|
leur composants,...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Changer le favicon en fonction de la couleur accentique.
|
||||||
|
Revoir la plate-forme
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Proposer un Text-to-speech dans le composer de message via un Modèle comme whisper ou autre (voir tts)
|
||||||
|
le composer message qui fait les message vocaux, doit respecter la DA du site, un beau truc, avec les waves form jolie du message vocal en real time, proposer de stoper la vocal, transcribe en texte, proposer l'édition avant envoie
|
||||||
|
|
||||||
|
---- pour les telemtry des nodes ollama
|
||||||
|
Vraiment rendre le truc super clean: telemtry avec très beau graphe, dock bar, pour changer le type de graphique, faire une vrai sous-page dans le admin center, avec de magnifique graphe, mangifique tooltip vraiment sympathique, voir le host, kernel, uptime, network, ip, etc
|
||||||
|
|
||||||
|
--- pour les waves formes du message vocal, rendre le tout vraiment vraiment beau, un beau composer unique.
|
||||||
|
--- Nouvel outil, l'ia en temps réel:
|
||||||
|
Faire un Speech-To-Speech, soit avec whisper + LLM ,etc proposer à l'utilisateur sont combo modèle + très belle animation quand le modèle parle, un peu style siri ai. (Apple Inteligence) , outil potentiel à inclur: FlowerlyTTS local ou bien modèle whisper , speech2speech. Proposer dans l'admin center sa configuration , avec les outils, etc...
|
||||||
|
Le modèle doit détecter la langue par le texte/etc. l'user peut customiser l'accent sur sa langue configuré(accent féminin, accent masculin, avec preview audio sur un text déjà pré-encoder: Exemple:
|
||||||
|
"Salut, euhm, comment je peux t'aider aujourd'hui ?"> voix grave héistante
|
||||||
|
"5 sur 5 je recois! Que-veux-tu ?" voix grave ton serieux
|
||||||
|
"Owwww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi " voix fémine, mignonne
|
||||||
|
"Hey , que puis-je faire pour toi ? " voix féminine médium par exemple
|
||||||
|
|
||||||
|
Ces exemples sont les default voix preview . dans l'admin center , l'utilisateur peut les modifier /revenir à défaut
|
||||||
|
L'utilisateur doit pouvoir configurée ces ai,vocaux,voix,langue dans une sous-section dédier dans ces paramètres.
|
||||||
|
|
||||||
|
--- actually the favicon have an background svg as the same color of the accenthique theme. should be indepandant .
|
||||||
|
white = day theme
|
||||||
|
black / night theme
|
||||||
|
witht the flower itself following thr accentic color
|
||||||
|
|
||||||
|
|
||||||
|
-- in specify
|
||||||
|
specify the model used in each footer of assistant message
|
||||||
|
like <model> <button>
|
||||||
|
|
||||||
|
|
||||||
|
---- pouvoir dans admin centers, sur users, envoyez un lien mdp reset à un user
|
||||||
|
|
||||||
|
|
||||||
|
--- <input type="color" aria-label="Custom accent color" class="absolute inset-0 cursor-pointer opacity-0" value="#10b981">
|
||||||
|
|
||||||
|
Ajouter un vrai composant pour pouvoir chosisir la couleur accentique sans dépendre du système.
|
||||||
|
Utiliser un vrai copiosant come shadcdn.
|
||||||
|
|
||||||
|
Avec un vrai accent color picket etpouvoir mettre sontpropr e code hex pour la couleur.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/l149xpcs2ywk0kl4l2lk0avddfviarnq-kisassistant-0.2.0
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { db } 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 async function initAccessTables(): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
const autoId = d.kind === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||||
|
await d.exec(`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 BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
if (d.kind === "sqlite") {
|
||||||
|
await repairSqliteAccessTables();
|
||||||
|
}
|
||||||
|
await d.exec(`CREATE TABLE IF NOT EXISTS access_messages (
|
||||||
|
id ${autoId},
|
||||||
|
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-only repairs for DBs shaped by older builds (PRAGMA / sqlite_master
|
||||||
|
* introspection + DROP COLUMN don't exist on Postgres; fresh Postgres tables
|
||||||
|
* are created clean above).
|
||||||
|
*/
|
||||||
|
async function repairSqliteAccessTables(): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
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
|
||||||
|
)`;
|
||||||
|
// 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 = await d.all<{ name: string }>("PRAGMA table_info(access_requests)");
|
||||||
|
if (cols.some((c) => c.name === "token_hash")) {
|
||||||
|
try {
|
||||||
|
await d.exec("PRAGMA foreign_keys=OFF");
|
||||||
|
try {
|
||||||
|
await d.exec("ALTER TABLE access_requests DROP COLUMN token_hash");
|
||||||
|
} catch {
|
||||||
|
// SQLite builds without DROP COLUMN support: rebuild table.
|
||||||
|
await d.exec("ALTER TABLE access_requests RENAME TO access_requests_old");
|
||||||
|
await d.exec(createRequests);
|
||||||
|
await d.exec(`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`);
|
||||||
|
await d.exec("DROP TABLE access_requests_old");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await d.exec("PRAGMA foreign_keys=ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 = await d.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'");
|
||||||
|
if (row?.sql?.includes("access_requests_old")) {
|
||||||
|
await d.exec("PRAGMA foreign_keys=OFF");
|
||||||
|
try {
|
||||||
|
await d.exec("DROP TABLE IF EXISTS access_messages_old");
|
||||||
|
await d.exec("ALTER TABLE access_messages RENAME TO access_messages_old");
|
||||||
|
await d.exec(createMessages);
|
||||||
|
await d.exec(`INSERT INTO access_messages (id, request_id, author, body, created_at)
|
||||||
|
SELECT id, request_id, author, body, created_at FROM access_messages_old`);
|
||||||
|
await d.exec("DROP TABLE access_messages_old");
|
||||||
|
} finally {
|
||||||
|
await d.exec("PRAGMA foreign_keys=ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave schema as-is; errors will surface on write.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccessRequest(opts: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
}): Promise<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,
|
||||||
|
};
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at,
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessRequest(id: string): Promise<AccessRequest | null> {
|
||||||
|
return db().get<AccessRequest>("SELECT * FROM access_requests WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open (non-terminal) request already reserving this username or email. */
|
||||||
|
export async function findOpenAccessRequest(username: string, email: string): Promise<AccessRequest | null> {
|
||||||
|
return db().get<AccessRequest>(
|
||||||
|
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
|
||||||
|
username, email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccessRequests(): Promise<(AccessRequest & { message_count: number })[]> {
|
||||||
|
return db().all<AccessRequest & { message_count: number }>(
|
||||||
|
`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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAccessStatus(id: string, status: AccessStatus, reason: string): Promise<AccessRequest | null> {
|
||||||
|
await db().run("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?", status, reason, Date.now(), id);
|
||||||
|
return getAccessRequest(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccessMessages(requestId: string): Promise<AccessMessage[]> {
|
||||||
|
return db().all<AccessMessage>("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC", requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAccessMessage(requestId: string, author: "user" | "admin", body: string): Promise<AccessMessage> {
|
||||||
|
const now = Date.now();
|
||||||
|
const id = await db().insertReturningId(
|
||||||
|
"INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
requestId, author, body, now,
|
||||||
|
);
|
||||||
|
await db().run("UPDATE access_requests SET updated_at = ? WHERE id = ?", now, requestId);
|
||||||
|
return { id, request_id: requestId, author, body, created_at: now };
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { AccessMessage, AccessRequest } from "./access.ts";
|
||||||
|
import type { Report } from "./reports.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
|
||||||
|
* plus content-report events, driving the AdminCenter badges + lists.
|
||||||
|
*
|
||||||
|
* Every event carries its full payload: clients apply it straight to state
|
||||||
|
* (no HTTP refetch on event — the socket IS the data, not a hint to poll).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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; message: AccessMessage; message_count: number }
|
||||||
|
| { type: "report_created"; report: Report }
|
||||||
|
| { type: "report_status"; report: Report };
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Ticket-scoped events route to that ticket's socket; report events and
|
||||||
|
// everything else fan out to the admin firehose only.
|
||||||
|
const ticketId = "request_id" in evt ? evt.request_id : null;
|
||||||
|
for (const s of [...subscribers]) {
|
||||||
|
if (s.isAdmin && s.ticketId === null) {
|
||||||
|
// Admin firehose: every event.
|
||||||
|
} else if (s.ticketId === null || s.ticketId !== ticketId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!s.send(data)) subscribers.delete(s);
|
||||||
|
} catch {
|
||||||
|
subscribers.delete(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
getSession,
|
||||||
|
getSetting,
|
||||||
|
getUserById,
|
||||||
|
getUserByUsername,
|
||||||
|
toPublicUser,
|
||||||
|
type PublicUser,
|
||||||
|
} from "./db.ts";
|
||||||
|
|
||||||
|
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 async function login(username: string, key: string): Promise<PublicUser | null> {
|
||||||
|
if (username === "admin") {
|
||||||
|
if (!checkAdminKey(key)) return null;
|
||||||
|
const admin = await getUserByUsername("admin");
|
||||||
|
if (!admin) return null;
|
||||||
|
return toPublicUser(admin);
|
||||||
|
}
|
||||||
|
const u = await 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 server-side). */
|
||||||
|
export async function issueToken(user: PublicUser): Promise<{ token: string; expiresAt: number }> {
|
||||||
|
const token = randomBytes(32).toString("hex"); // 256-bit
|
||||||
|
const expiresAt = Date.now() + config.sessionTtlMs;
|
||||||
|
await createSession(sha256hex(token), user.id, expiresAt);
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<PublicUser | null> {
|
||||||
|
const s = await getSession(sha256hex(token));
|
||||||
|
if (!s) return null;
|
||||||
|
const u = await getUserById(s.user_id);
|
||||||
|
if (!u) {
|
||||||
|
await deleteSession(sha256hex(token));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toPublicUser(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeToken(token: string): Promise<void> {
|
||||||
|
await 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dedicated rate limiter for password recovery (admin-configurable) ---
|
||||||
|
// Separate bucket from login: recovery is anonymous-by-design (always-ok
|
||||||
|
// answers) so it needs its own, usually stricter, envelope.
|
||||||
|
|
||||||
|
const recoveryAttempts = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function clampSettingInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitMax(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_max", "5"), 1, 100, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoveryLimitWindowMs(): Promise<number> {
|
||||||
|
return clampSettingInt(await getSetting("recovery_limit_window_min", "60"), 1, 1440, 60) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isRecoveryLimited(ip: string): Promise<{ limited: boolean; retryAfterSec: number }> {
|
||||||
|
const windowMs = await recoveryLimitWindowMs();
|
||||||
|
const max = await recoveryLimitMax();
|
||||||
|
const now = Date.now();
|
||||||
|
const list = (recoveryAttempts.get(ip) ?? []).filter((t) => t > now - windowMs);
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
if (list.length < max) return { limited: false, retryAfterSec: 0 };
|
||||||
|
return { limited: true, retryAfterSec: Math.max(1, Math.ceil((list[0]! + windowMs - now) / 1000)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecoveryAttempt(ip: string): void {
|
||||||
|
const list = recoveryAttempts.get(ip) ?? [];
|
||||||
|
list.push(Date.now());
|
||||||
|
recoveryAttempts.set(ip, list);
|
||||||
|
}
|
||||||
+178
@@ -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);
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
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"),
|
||||||
|
|
||||||
|
// Database: empty POSTGRESQL_URL = local SQLite in DATA_DIR (default).
|
||||||
|
// Set POSTGRESQL_URL=postgres://user:pass@host:5432/db to run on Postgres
|
||||||
|
// (typically a docker container) via Bun's native SQL driver instead.
|
||||||
|
postgresqlUrl: env("POSTGRESQL_URL", ""),
|
||||||
|
|
||||||
|
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),
|
||||||
|
|
||||||
|
// Neural TTS tool (ElevenLabs / OpenAI / local piper, first configured
|
||||||
|
// wins unless TTS_PROVIDER forces one). Without any of these the tool
|
||||||
|
// reports unavailable and clients fall back to browser speech.
|
||||||
|
elevenlabsApiKey: env("ELEVENLABS_API_KEY", ""),
|
||||||
|
elevenlabsVoice: env("ELEVENLABS_VOICE", ""),
|
||||||
|
piperModel: env("PIPER_MODEL", ""),
|
||||||
|
ttsVoice: env("TTS_VOICE", "alloy"),
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { db, initDb, closeDb, dbKind } from "./db/client.ts";
|
||||||
|
import type { DbKind } from "./db/client.ts";
|
||||||
|
|
||||||
|
export { db, initDb, closeDb, dbKind };
|
||||||
|
export type { DbKind };
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
|
||||||
|
model: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function getUserByUsername(username: string): Promise<UserRow | null> {
|
||||||
|
return db().get<UserRow>("SELECT * FROM users WHERE username = ?", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string): Promise<UserRow | null> {
|
||||||
|
return db().get<UserRow>("SELECT * FROM users WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<UserRow[]> {
|
||||||
|
return db().all<UserRow>("SELECT * FROM users ORDER BY created_at ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): Promise<UserRow> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): Promise<UserRow | null> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: (string | number)[] = [];
|
||||||
|
if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); }
|
||||||
|
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) {
|
||||||
|
await db().run(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
|
||||||
|
}
|
||||||
|
if (patch.username !== undefined) {
|
||||||
|
// Username is denormalized onto past reports — keep the triage list consistent.
|
||||||
|
await db().run("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?", patch.username, id);
|
||||||
|
}
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
const convs = await d.all<{ id: string }>("SELECT id FROM conversations WHERE user_id = ?", id);
|
||||||
|
await d.transaction(async (tx) => {
|
||||||
|
for (const c of convs) {
|
||||||
|
await tx.run("DELETE FROM messages WHERE conv_id = ?", c.id);
|
||||||
|
await tx.run("DELETE FROM shares WHERE conv_id = ?", c.id);
|
||||||
|
await tx.run("DELETE FROM conversations WHERE id = ?", c.id);
|
||||||
|
}
|
||||||
|
await tx.run("DELETE FROM sessions WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM resets WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM user_provider_keys WHERE user_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM users WHERE id = ?", id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sessions ---
|
||||||
|
|
||||||
|
export async function createSession(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
tokenHash, userId, now, expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(tokenHash: string): Promise<SessionRow | null> {
|
||||||
|
const s = await db().get<SessionRow>("SELECT * FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.expires_at <= Date.now()) {
|
||||||
|
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(tokenHash: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserSessions(userId: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM sessions WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- conversations ---
|
||||||
|
|
||||||
|
export async function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): Promise<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,
|
||||||
|
};
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at,
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConversations(userId: string): Promise<ConversationRow[]> {
|
||||||
|
return db().all<ConversationRow>(
|
||||||
|
"SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC", userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archived chats: hidden from the sidebar, managed in settings. */
|
||||||
|
export async function listArchivedConversations(userId: string): Promise<ConversationRow[]> {
|
||||||
|
return db().all<ConversationRow>(
|
||||||
|
"SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC", userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Un)archive a conversation. archived_at is left out of updated_at on
|
||||||
|
* purpose so an unarchived chat returns to its chronological place.
|
||||||
|
*/
|
||||||
|
export async function setConversationArchived(id: string, archived: boolean): Promise<ConversationRow | null> {
|
||||||
|
await db().run("UPDATE conversations SET archived_at = ? WHERE id = ?", archived ? Date.now() : 0, id);
|
||||||
|
return getConversation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversation(id: string): Promise<ConversationRow | null> {
|
||||||
|
return db().get<ConversationRow>("SELECT * FROM conversations WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): Promise<ConversationRow | null> {
|
||||||
|
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)); }
|
||||||
|
await db().run(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
|
||||||
|
return getConversation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchConversation(id: string, model?: string): Promise<void> {
|
||||||
|
if (model) {
|
||||||
|
await db().run("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?", Date.now(), model.slice(0, 160), id);
|
||||||
|
} else {
|
||||||
|
await db().run("UPDATE conversations SET updated_at = ? WHERE id = ?", Date.now(), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConversation(id: string): Promise<void> {
|
||||||
|
await db().transaction(async (tx) => {
|
||||||
|
await tx.run("DELETE FROM messages WHERE conv_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM shares WHERE conv_id = ?", id);
|
||||||
|
await tx.run("DELETE FROM conversations WHERE id = ?", id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMessage(convId: string, role: string, content: string, model = ""): Promise<void> {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
convId, role, content, model.slice(0, 160), Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessages(convId: string): Promise<MessageRow[]> {
|
||||||
|
return db().all<MessageRow>("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500", convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- password-recovery resets (one-time, hashed, expiring) ---
|
||||||
|
|
||||||
|
export interface ResetRow {
|
||||||
|
token_hash: string;
|
||||||
|
user_id: string;
|
||||||
|
created_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReset(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
|
||||||
|
// One active reset per account: replace any previous one.
|
||||||
|
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
tokenHash, userId, Date.now(), expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekReset(tokenHash: string): Promise<ResetRow | null> {
|
||||||
|
const r = await db().get<ResetRow>("SELECT * FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
|
if (!r) return null;
|
||||||
|
if (r.expires_at <= Date.now()) {
|
||||||
|
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
|
||||||
|
export async function consumeReset(tokenHash: string): Promise<ResetRow | null> {
|
||||||
|
const r = await peekReset(tokenHash);
|
||||||
|
if (!r) return null;
|
||||||
|
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserResets(userId: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- platform settings (admin-controlled feature flags) ---
|
||||||
|
|
||||||
|
export async function getSetting(key: string, fallback: string): Promise<string> {
|
||||||
|
const r = await db().get<{ value: string }>("SELECT value FROM settings WHERE key = ?", key);
|
||||||
|
return r?.value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(key: string, value: string): Promise<void> {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||||
|
key, value, Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public self-registration switch (default off = admin-created accounts only). */
|
||||||
|
export async function isRegistrationEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("registration_enabled", "0")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for the OCR platform tool (default on). */
|
||||||
|
export async function isOcrToolEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("tools_ocr_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for the neural TTS platform tool (default on). */
|
||||||
|
export async function isTtsToolEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("tools_tts_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for message reports / flag button (default on). */
|
||||||
|
export async function isReportsEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("reports_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for self-service username changes (default on; admins bypass). */
|
||||||
|
export async function isUsernameChangeEnabled(): Promise<boolean> {
|
||||||
|
return (await getSetting("username_change_enabled", "1")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- public shares ---
|
||||||
|
|
||||||
|
export async function ensureShare(convId: string): Promise<string> {
|
||||||
|
const existing = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||||
|
if (existing) return existing.public_id;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const publicId = randomBytes(9).toString("base64url");
|
||||||
|
try {
|
||||||
|
await db().run("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)", convId, publicId, Date.now());
|
||||||
|
return publicId;
|
||||||
|
} catch {
|
||||||
|
// collision — retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("could not generate share id");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShareByConv(convId: string): Promise<string | null> {
|
||||||
|
const r = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
|
||||||
|
return r?.public_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShare(convId: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM shares WHERE conv_id = ?", convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShareByPublic(publicId: string): Promise<{ conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null> {
|
||||||
|
const s = await db().get<{ conv_id: string; created_at: number }>("SELECT * FROM shares WHERE public_id = ?", publicId);
|
||||||
|
if (!s) return null;
|
||||||
|
const conv = await getConversation(s.conv_id);
|
||||||
|
if (!conv) return null;
|
||||||
|
const author = await getUserById(conv.user_id);
|
||||||
|
return {
|
||||||
|
conv,
|
||||||
|
messages: (await 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { SQL } from "bun";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database driver abstraction (SQLite default, Postgres opt-in).
|
||||||
|
*
|
||||||
|
* - SQLite: Bun's native `bun:sqlite` (synchronous underneath, single file
|
||||||
|
* in DATA_DIR). Zero setup, the historical default.
|
||||||
|
* - Postgres: Bun's native `bun:sql` driver (`new SQL(url)`), typically a
|
||||||
|
* docker container pointed at by POSTGRESQL_URL.
|
||||||
|
*
|
||||||
|
* All queries in the codebase use `?` placeholders (SQLite style); the
|
||||||
|
* Postgres client rewrites them to `$1, $2, …` (quote/comment aware).
|
||||||
|
* Postgres returns BIGINT cells as JS `bigint` — the client normalizes them
|
||||||
|
* to `number` (every integer column fits: ms timestamps, counts, ids).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DbKind = "sqlite" | "postgres";
|
||||||
|
export type SqlParam = string | number | bigint | null;
|
||||||
|
export type SqlParams = SqlParam[];
|
||||||
|
|
||||||
|
export interface DbRunResult {
|
||||||
|
changes: number;
|
||||||
|
lastInsertId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbTx {
|
||||||
|
get<T>(sql: string, ...params: SqlParams): Promise<T | null>;
|
||||||
|
all<T>(sql: string, ...params: SqlParams): Promise<T[]>;
|
||||||
|
run(sql: string, ...params: SqlParams): Promise<DbRunResult>;
|
||||||
|
exec(sql: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbClient extends DbTx {
|
||||||
|
readonly kind: DbKind;
|
||||||
|
/** INSERT a single row and get its auto id back (RETURNING on pg). */
|
||||||
|
insertReturningId(sql: string, ...params: SqlParams): Promise<number>;
|
||||||
|
transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite `?` placeholders to `$n` for Postgres (skips quoted literals). */
|
||||||
|
export function sqliteToPgPlaceholders(sql: string): string {
|
||||||
|
let out = "";
|
||||||
|
let n = 0;
|
||||||
|
let i = 0;
|
||||||
|
const len = sql.length;
|
||||||
|
while (i < len) {
|
||||||
|
const c = sql[i]!;
|
||||||
|
// Single-quoted string literal ('' = escaped quote).
|
||||||
|
if (c === "'") {
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
while (i < len) {
|
||||||
|
out += sql[i]!;
|
||||||
|
if (sql[i] === "'") {
|
||||||
|
if (sql[i + 1] === "'") { out += sql[i + 1]!; i += 2; continue; }
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Double-quoted identifier ("..." with "" escape).
|
||||||
|
if (c === '"') {
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
while (i < len) {
|
||||||
|
out += sql[i]!;
|
||||||
|
if (sql[i] === '"') {
|
||||||
|
if (sql[i + 1] === '"') { out += sql[i + 1]!; i += 2; continue; }
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Line comment.
|
||||||
|
if (c === "-" && sql[i + 1] === "-") {
|
||||||
|
while (i < len && sql[i] !== "\n") out += sql[i++]!;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Block comment.
|
||||||
|
if (c === "/" && sql[i + 1] === "*") {
|
||||||
|
out += "/*";
|
||||||
|
i += 2;
|
||||||
|
while (i < len && !(sql[i] === "*" && sql[i + 1] === "/")) out += sql[i++]!;
|
||||||
|
out += "*/";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "?") {
|
||||||
|
n++;
|
||||||
|
out += `$${n}`;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively normalize driver row values (bigint -> number). */
|
||||||
|
export function normalizeValue<T>(v: T): T {
|
||||||
|
if (typeof v === "bigint") return Number(v) as T;
|
||||||
|
if (Array.isArray(v)) return v.map(normalizeValue) as T;
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
for (const k of Object.keys(o)) o[k] = normalizeValue(o[k]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redacted one-liner for logs (never print credentials). */
|
||||||
|
export function redactedUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return `${u.protocol}//${u.host}${u.pathname}`;
|
||||||
|
} catch {
|
||||||
|
return "(unparseable url)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SQLite implementation (bun:sqlite, sync underneath) ---
|
||||||
|
|
||||||
|
class SqliteTx implements DbTx {
|
||||||
|
constructor(private d: Database) {}
|
||||||
|
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
|
||||||
|
return (this.d.query(sql).get(...params) as T | null) ?? null;
|
||||||
|
}
|
||||||
|
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
|
||||||
|
return this.d.query(sql).all(...params) as T[];
|
||||||
|
}
|
||||||
|
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
|
||||||
|
const r = this.d.query(sql).run(...params) as { changes: number; lastInsertRowid: number | bigint };
|
||||||
|
return { changes: Number(r.changes), lastInsertId: Number(r.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
async exec(sql: string): Promise<void> {
|
||||||
|
this.d.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SqliteClient extends SqliteTx implements DbClient {
|
||||||
|
readonly kind: DbKind = "sqlite";
|
||||||
|
constructor(private db: Database) {
|
||||||
|
super(db);
|
||||||
|
}
|
||||||
|
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
|
||||||
|
const r = await this.run(sql, ...params);
|
||||||
|
return r.lastInsertId ?? 0;
|
||||||
|
}
|
||||||
|
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
|
||||||
|
// Manual BEGIN/COMMIT (bun:sqlite's transaction() only takes sync
|
||||||
|
// callbacks, so it can't wrap our async bodies — this is equivalent).
|
||||||
|
this.db.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const out = await fn(this);
|
||||||
|
this.db.exec("COMMIT");
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
this.db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// already rolled back / never began — report the original error
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Postgres implementation (Bun native bun:sql) ---
|
||||||
|
|
||||||
|
type PgRunner = {
|
||||||
|
unsafe: (sql: string, params?: SqlParams) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PgTx implements DbTx {
|
||||||
|
constructor(private runner: PgRunner) {}
|
||||||
|
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
|
||||||
|
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
|
||||||
|
return rows.length > 0 ? normalizeValue(rows[0]!) : null;
|
||||||
|
}
|
||||||
|
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
|
||||||
|
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
|
||||||
|
return normalizeValue(rows);
|
||||||
|
}
|
||||||
|
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
|
||||||
|
const res = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as { count?: unknown } | null;
|
||||||
|
const changes = typeof res?.count === "number" ? res.count : 0;
|
||||||
|
return { changes, lastInsertId: null };
|
||||||
|
}
|
||||||
|
async exec(sql: string): Promise<void> {
|
||||||
|
await this.runner.unsafe(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PgClient extends PgTx implements DbClient {
|
||||||
|
readonly kind: DbKind = "postgres";
|
||||||
|
constructor(private sql: SQL) {
|
||||||
|
super(sql as unknown as PgRunner);
|
||||||
|
}
|
||||||
|
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
|
||||||
|
const row = await this.get<{ id: number | bigint }>(`${sql} RETURNING id`, ...params);
|
||||||
|
return row ? Number(row.id) : 0;
|
||||||
|
}
|
||||||
|
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
|
||||||
|
return this.sql.begin(async (tx) => fn(new PgTx(tx as unknown as PgRunner)));
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.sql.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- singleton ---
|
||||||
|
|
||||||
|
let client: DbClient | null = null;
|
||||||
|
|
||||||
|
export function db(): DbClient {
|
||||||
|
if (!client) throw new Error("database not initialized — call initDb() first");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbKind(): DbKind {
|
||||||
|
return client?.kind ?? (config.postgresqlUrl ? "postgres" : "sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect + create schema idempotently. Fails fast when unreachable. */
|
||||||
|
export async function initDb(): Promise<DbClient> {
|
||||||
|
if (client) return client;
|
||||||
|
const url = config.postgresqlUrl.trim();
|
||||||
|
if (url) {
|
||||||
|
if (!/^postgres(ql)?:\/\//i.test(url)) {
|
||||||
|
throw new Error(`POSTGRESQL_URL must start with postgres:// or postgresql:// (got ${redactedUrl(url)})`);
|
||||||
|
}
|
||||||
|
const sql = new SQL(url);
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1 AS ok`;
|
||||||
|
} catch (e) {
|
||||||
|
await sql.close().catch(() => {});
|
||||||
|
throw new Error(`cannot reach Postgres at ${redactedUrl(url)}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
client = new PgClient(sql);
|
||||||
|
await initPgSchema(client);
|
||||||
|
console.log(`[db] using postgres (${redactedUrl(url)})`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
const dbo = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
|
||||||
|
dbo.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
dbo.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
client = new SqliteClient(dbo);
|
||||||
|
await initSqliteSchema(client);
|
||||||
|
console.log(`[db] using sqlite (${join(config.dataDir, "kisassistant.db")})`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- core schema (users/sessions/conversations/messages/shares/resets/settings/providers) ---
|
||||||
|
|
||||||
|
const CORE_TABLES_SQLITE = `
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CORE_TABLES_PG = `
|
||||||
|
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 BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT 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 BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages(
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at BIGINT 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 BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resets(
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings(
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT 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 BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, provider)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Columns added after the initial schema — idempotent on both drivers. */
|
||||||
|
const LATER_COLUMNS = [
|
||||||
|
"ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Split a multi-statement DDL string into single statements. */
|
||||||
|
function splitStatements(ddl: string): string[] {
|
||||||
|
return ddl
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminRow(c: DbClient): Promise<void> {
|
||||||
|
const admin = await c.get<{ id: string }>("SELECT id FROM users WHERE username = 'admin'");
|
||||||
|
if (!admin) {
|
||||||
|
const { randomUUID } = await import("node:crypto");
|
||||||
|
await c.run(
|
||||||
|
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
randomUUID(), "admin", "Administrator", "", "auto", "", Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSqliteSchema(c: DbClient): Promise<void> {
|
||||||
|
await c.exec(CORE_TABLES_SQLITE);
|
||||||
|
// Column guards via PRAGMA (kept from the single-driver era).
|
||||||
|
const cols = await c.all<{ name: string }>("PRAGMA table_info(users)");
|
||||||
|
const names = new Set(cols.map((col) => col.name));
|
||||||
|
if (!names.has("email")) await c.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!names.has("totp_secret")) await c.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!names.has("report_shadowbanned")) await c.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
|
const convCols = await c.all<{ name: string }>("PRAGMA table_info(conversations)");
|
||||||
|
if (!convCols.some((col) => col.name === "archived_at")) {
|
||||||
|
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
const msgCols = await c.all<{ name: string }>("PRAGMA table_info(messages)");
|
||||||
|
if (!msgCols.some((col) => col.name === "model")) {
|
||||||
|
await c.exec("ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
await ensureAdminRow(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPgSchema(c: DbClient): Promise<void> {
|
||||||
|
for (const stmt of splitStatements(CORE_TABLES_PG)) await c.exec(stmt);
|
||||||
|
// ADD COLUMN IF NOT EXISTS is natively idempotent on Postgres.
|
||||||
|
for (const ddl of LATER_COLUMNS) {
|
||||||
|
const m = ddl.match(/^ALTER TABLE (\S+) ADD COLUMN (\S+) (.+)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
await c.exec(`ALTER TABLE ${m[1]} ADD COLUMN IF NOT EXISTS ${m[2]} ${m[3]}`);
|
||||||
|
}
|
||||||
|
await ensureAdminRow(c);
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { config } from "../config.ts";
|
||||||
|
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
||||||
|
import {
|
||||||
|
effectiveOllamaNodes,
|
||||||
|
getOllamaTimeoutMs,
|
||||||
|
pickOllamaRoute,
|
||||||
|
recordOllamaStat,
|
||||||
|
type EffectiveNode,
|
||||||
|
} from "../ollamaNodes.ts";
|
||||||
|
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
|
interface OllamaTag {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaDone {
|
||||||
|
done?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: { content?: string };
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
eval_duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local LLM via Ollama (multi-node pool). 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Model list merged across every enabled node (deduped by name). */
|
||||||
|
async listModels(): Promise<DriverModel[]> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const nodes = await effectiveOllamaNodes();
|
||||||
|
if (nodes.length === 0) throw new Error("no ollama node available (all disabled or weight 0)");
|
||||||
|
const seen = new Map<string, DriverModel>();
|
||||||
|
const errors: string[] = [];
|
||||||
|
await Promise.all(nodes.map(async (n) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${n.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
const data = (await res.json()) as { models?: OllamaTag[] };
|
||||||
|
for (const m of data.models ?? []) {
|
||||||
|
if (typeof m.name === "string" && !seen.has(m.name)) {
|
||||||
|
seen.set(m.name, { id: `ollama:${m.name}`, name: m.name, driver: "ollama", label: m.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${n.name}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const models = [...seen.values()];
|
||||||
|
driverLog(this.name, `listModels -> ${models.length} model(s) across ${nodes.length} node(s) (${fmtMs(Date.now() - t0)})${errors.length > 0 ? ` [skipped: ${errors.join("; ")}]` : ""}`);
|
||||||
|
if (models.length === 0 && errors.length > 0) throw new Error(`ollama listModels failed: ${errors.join("; ")}`);
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 timeoutMs = await getOllamaTimeoutMs();
|
||||||
|
const { order } = await pickOllamaRoute();
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (const node of order) {
|
||||||
|
// Failover only while nothing was produced yet: once tokens reached
|
||||||
|
// the client, restarting on another node would duplicate the reply.
|
||||||
|
let produced = false;
|
||||||
|
const counting: ChatOptions = {
|
||||||
|
...opts,
|
||||||
|
onToken: (t) => {
|
||||||
|
produced = true;
|
||||||
|
opts.onToken?.(t);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
yield* this.streamFromNode(node, messages, counting, timeoutMs);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// Caller hangup: never fail over (nobody is listening anymore).
|
||||||
|
if (opts.signal?.aborted) throw e;
|
||||||
|
if (produced) throw e;
|
||||||
|
lastError = e as Error;
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node ${node.name} failed, failing over: ${lastError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error("ollama chat failed on every node");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One streaming attempt against a single node (throws on any failure). */
|
||||||
|
private async *streamFromNode(
|
||||||
|
node: EffectiveNode,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
opts: ChatOptions,
|
||||||
|
timeoutMs: number,
|
||||||
|
): AsyncGenerator<string, void, void> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} stream=open`);
|
||||||
|
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||||
|
if (opts.signal) signals.push(opts.signal);
|
||||||
|
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(signals) : signals[0]!;
|
||||||
|
const res = await fetch(`${node.host}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: opts.model, messages, stream: true }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: 0, eval_tokens: 0, model: opts.model });
|
||||||
|
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 promptTokens = 0;
|
||||||
|
let evalTokens = 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 OllamaDone;
|
||||||
|
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 (typeof obj.prompt_eval_count === "number") promptTokens = obj.prompt_eval_count;
|
||||||
|
if (typeof obj.eval_count === "number") evalTokens = obj.eval_count;
|
||||||
|
if (obj.done) {
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) continue; // partial line, ignore
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
driverLog(this.name, `chat model=${opts.model} node=${node.name} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
// Truncated stream (no done:true): still a usable partial reply.
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
|
} catch (e) {
|
||||||
|
const name = (e as Error).name;
|
||||||
|
status = name === "AbortError" ? "aborted-by-caller" : name === "TimeoutError" ? "timeout" : `ERROR: ${(e as Error).message}`;
|
||||||
|
// Caller aborts are not node failures — don't pollute telemetry.
|
||||||
|
if (name !== "AbortError" || !opts.signal?.aborted) {
|
||||||
|
await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model });
|
||||||
|
}
|
||||||
|
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} node=${node.name} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
/** True when served through the caller's own provider key (BYOK) rather
|
||||||
|
* than the platform key — the UI lists these in their own section. */
|
||||||
|
personal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+2052
File diff suppressed because it is too large
Load Diff
+266
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { db, 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 async function getModelPolicy(): Promise<ModelPolicy> {
|
||||||
|
const raw = await 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 async function setModelPolicyEntry(
|
||||||
|
modelId: string,
|
||||||
|
patch: { enabled?: boolean; hourly?: number; daily?: number },
|
||||||
|
): Promise<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 = await 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;
|
||||||
|
await setSetting(SETTINGS_KEY, JSON.stringify(policy));
|
||||||
|
return { ...DEFAULT_POLICY, ...policy[modelId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initModelUsageTables(): Promise<void> {
|
||||||
|
// "window" is quoted: WINDOW is a reserved keyword on Postgres (and the
|
||||||
|
// quotes are harmless on SQLite).
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS model_usage (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
"window" TEXT NOT NULL,
|
||||||
|
bucket BIGINT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, model, "window", bucket)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buckets(now = Date.now()): { h: number; d: number } {
|
||||||
|
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): Promise<number> {
|
||||||
|
const row = await db().get<{ n: number }>(
|
||||||
|
'SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND "window" = ? AND bucket = ?',
|
||||||
|
userId, modelId, window, bucket,
|
||||||
|
);
|
||||||
|
return row?.n ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
|
||||||
|
|
||||||
|
export async function checkModelAccess(userId: string, modelId: string): Promise<ModelAccess> {
|
||||||
|
const p = policyFor(await getModelPolicy(), modelId);
|
||||||
|
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
|
||||||
|
const { h, d } = buckets();
|
||||||
|
if (p.hourly > 0 && (await 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 && (await 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 async function recordModelUse(userId: string, modelId: string): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
const { h, d: day } = buckets();
|
||||||
|
await d.run(`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`, userId, modelId, h);
|
||||||
|
await d.run(`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`, userId, modelId, day);
|
||||||
|
await d.run('DELETE FROM model_usage WHERE ("window" = \'h\' AND bucket < ?) OR ("window" = \'d\' AND bucket < ?)', h - 1, day - 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { db, dbKind, getSetting } from "./db.ts";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-node Ollama pool + generation telemetry.
|
||||||
|
*
|
||||||
|
* - `ollama_nodes`: admin-managed extra Ollama hosts. The env-configured
|
||||||
|
* host (`OLLAMA_HOST`) always exists as the implicit "default" node; its
|
||||||
|
* traffic share is `ollama_default_weight` (0 = never use it).
|
||||||
|
* - Routing is firewall-like: nodes are ordered by weight (highest first)
|
||||||
|
* and each generation picks a node by weighted random among the enabled
|
||||||
|
* ones, failing over to the next heaviest node on error/timeout.
|
||||||
|
* - `ollama_stats`: one row per generation attempt (node, ok, latency,
|
||||||
|
* token counts from Ollama's final chunk) powering the per-node detail
|
||||||
|
* charts. Rows older than 35 days are pruned on write.
|
||||||
|
*
|
||||||
|
* NOTE on host metrics: Ollama exposes no CPU/RAM API — only per-model
|
||||||
|
* VRAM via /api/ps (surfaced in the node detail view). Load-over-time
|
||||||
|
* charts come from app-side telemetry (requests, errors, latency,
|
||||||
|
* tokens/s), which is also what drives routing decisions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OllamaNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveNode {
|
||||||
|
/** "default" for the env host, otherwise the row id. */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_ID = "default";
|
||||||
|
const STATS_RETENTION_MS = 35 * 86400_000;
|
||||||
|
|
||||||
|
export async function initOllamaNodes(): Promise<void> {
|
||||||
|
const autoId = dbKind() === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL UNIQUE,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE TABLE IF NOT EXISTS ollama_stats (
|
||||||
|
id ${autoId},
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
ts BIGINT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
eval_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
await db().exec(`CREATE INDEX IF NOT EXISTS idx_ollama_stats_node_ts ON ollama_stats(node_id, ts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToNode(r: Record<string, number | string>): OllamaNode {
|
||||||
|
return {
|
||||||
|
id: String(r.id),
|
||||||
|
name: String(r.name),
|
||||||
|
host: String(r.host),
|
||||||
|
weight: Number(r.weight),
|
||||||
|
enabled: Number(r.enabled) === 1,
|
||||||
|
created_at: Number(r.created_at),
|
||||||
|
updated_at: Number(r.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOllamaNodes(): Promise<OllamaNode[]> {
|
||||||
|
const rows = await db().all<Record<string, number | string>>(
|
||||||
|
"SELECT * FROM ollama_nodes ORDER BY weight DESC, created_at ASC",
|
||||||
|
);
|
||||||
|
return rows.map(rowToNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOllamaNode(id: string): Promise<OllamaNode | null> {
|
||||||
|
const row = await db().get<Record<string, number | string>>("SELECT * FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return row ? rowToNode(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newNodeId(): string {
|
||||||
|
return "node_" + randomBytes(9).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize + validate an Ollama base URL (scheme + host, no path). */
|
||||||
|
export function sanitizeNodeHost(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim().replace(/\/+$/, "");
|
||||||
|
if (t.length < 10 || t.length > 200) return null;
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(t);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
if (!u.hostname || u.username || u.password) return null;
|
||||||
|
// Drop default ports and any path/query/fragment for canonical form.
|
||||||
|
const port = u.port && !((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443"))
|
||||||
|
? `:${u.port}`
|
||||||
|
: "";
|
||||||
|
if (u.pathname !== "/" && u.pathname !== "") return null;
|
||||||
|
return `${u.protocol}//${u.hostname}${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNodeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.length === 0 || t.length > 60) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeWeight(raw: unknown): number | null {
|
||||||
|
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw > 1000) return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOllamaNode(opts: { name: string; host: string; weight: number; enabled: boolean }): Promise<OllamaNode> {
|
||||||
|
const now = Date.now();
|
||||||
|
const row: OllamaNode = { id: newNodeId(), ...opts, created_at: now, updated_at: now };
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO ollama_nodes (id, name, host, weight, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
row.id, row.name, row.host, row.weight, row.enabled ? 1 : 0, now, now,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOllamaNode(
|
||||||
|
id: string,
|
||||||
|
patch: { name?: string; host?: string; weight?: number; enabled?: boolean },
|
||||||
|
): Promise<OllamaNode | null> {
|
||||||
|
const cur = await getOllamaNode(id);
|
||||||
|
if (!cur) return null;
|
||||||
|
const next: OllamaNode = {
|
||||||
|
...cur,
|
||||||
|
name: patch.name ?? cur.name,
|
||||||
|
host: patch.host ?? cur.host,
|
||||||
|
weight: patch.weight ?? cur.weight,
|
||||||
|
enabled: patch.enabled ?? cur.enabled,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await db().run(
|
||||||
|
"UPDATE ollama_nodes SET name = ?, host = ?, weight = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
||||||
|
next.name, next.host, next.weight, next.enabled ? 1 : 0, next.updated_at, id,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOllamaNode(id: string): Promise<boolean> {
|
||||||
|
const r = await db().run("DELETE FROM ollama_nodes WHERE id = ?", id);
|
||||||
|
return r.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- effective pool (env default + custom rows) ---
|
||||||
|
|
||||||
|
function clampInt(raw: string, min: number, max: number, fallback: number): number {
|
||||||
|
const v = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(v)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generation timeout for Ollama chat requests (admin setting, seconds). */
|
||||||
|
export async function getOllamaTimeoutMs(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_timeout_s", "300"), 5, 3600, 300) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traffic weight of the implicit env node (0 = never use it). */
|
||||||
|
export async function getOllamaDefaultWeight(): Promise<number> {
|
||||||
|
return clampInt(await getSetting("ollama_default_weight", "100"), 0, 1000, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All routable nodes, heaviest first (default env node included). */
|
||||||
|
export async function effectiveOllamaNodes(): Promise<EffectiveNode[]> {
|
||||||
|
const out: EffectiveNode[] = [];
|
||||||
|
if (config.ollamaEnabled) {
|
||||||
|
const w = await getOllamaDefaultWeight();
|
||||||
|
if (w > 0) {
|
||||||
|
out.push({ id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const n of await listOllamaNodes()) {
|
||||||
|
if (n.enabled && n.weight > 0) out.push({ id: n.id, name: n.name, host: n.host, weight: n.weight });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.weight - a.weight);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the node for one generation (weighted random) + the failover order
|
||||||
|
* (picked node first, then heaviest-first). Throws when nothing is routable.
|
||||||
|
*/
|
||||||
|
export async function pickOllamaRoute(): Promise<{ first: EffectiveNode; order: EffectiveNode[] }> {
|
||||||
|
const nodes = await effectiveOllamaNodes();
|
||||||
|
if (nodes.length === 0) throw new Error('no ollama node available (all disabled or weight 0)');
|
||||||
|
const total = nodes.reduce((s, n) => s + n.weight, 0);
|
||||||
|
let r = Math.random() * total;
|
||||||
|
let first = nodes[0]!;
|
||||||
|
for (const n of nodes) {
|
||||||
|
r -= n.weight;
|
||||||
|
if (r <= 0) {
|
||||||
|
first = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = [first, ...nodes.filter((n) => n.id !== first.id)];
|
||||||
|
return { first, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an admin-selected node id (pull/delete/status) to its host. */
|
||||||
|
export async function resolveOllamaHost(nodeId: string | undefined): Promise<EffectiveNode> {
|
||||||
|
if (!nodeId || nodeId === DEFAULT_NODE_ID) {
|
||||||
|
return { id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: 0 };
|
||||||
|
}
|
||||||
|
const n = await getOllamaNode(nodeId);
|
||||||
|
if (!n) throw new Error("unknown ollama node");
|
||||||
|
return { id: n.id, name: n.name, host: n.host, weight: n.weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- telemetry ---
|
||||||
|
|
||||||
|
export async function recordOllamaStat(s: {
|
||||||
|
node_id: string;
|
||||||
|
ok: boolean;
|
||||||
|
latency_ms: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
eval_tokens: number;
|
||||||
|
model: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
await d.run(
|
||||||
|
"INSERT INTO ollama_stats (node_id, ts, ok, latency_ms, prompt_tokens, eval_tokens, model) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
s.node_id, Date.now(), s.ok ? 1 : 0, Math.max(0, Math.round(s.latency_ms)),
|
||||||
|
Math.max(0, Math.round(s.prompt_tokens)), Math.max(0, Math.round(s.eval_tokens)), s.model.slice(0, 160),
|
||||||
|
);
|
||||||
|
// Best-effort retention (indexed range delete, cheap).
|
||||||
|
try {
|
||||||
|
await d.run("DELETE FROM ollama_stats WHERE ts < ?", Date.now() - STATS_RETENTION_MS);
|
||||||
|
} catch {
|
||||||
|
// stats must never break generations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatsWindow = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
|
export function statsWindowMs(w: StatsWindow): number {
|
||||||
|
return { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 86400_000, "7d": 7 * 86400_000, "30d": 30 * 86400_000 }[w];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsBucket {
|
||||||
|
t: number;
|
||||||
|
req: number;
|
||||||
|
err: number;
|
||||||
|
avgMs: number | null;
|
||||||
|
promptTok: number;
|
||||||
|
evalTok: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStats {
|
||||||
|
window: StatsWindow;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null };
|
||||||
|
buckets: StatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKETS = 24;
|
||||||
|
|
||||||
|
export async function nodeStats(nodeId: string, window: StatsWindow): Promise<NodeStats> {
|
||||||
|
const to = Date.now();
|
||||||
|
const span = statsWindowMs(window);
|
||||||
|
const from = to - span;
|
||||||
|
const bucketMs = Math.floor(span / BUCKETS);
|
||||||
|
// GROUP BY output alias works on both SQLite and Postgres; AVG may come
|
||||||
|
// back as text (pg numeric) so every aggregate is coerced in JS.
|
||||||
|
const rows = await db().all<Record<string, number | string | null>>(
|
||||||
|
`SELECT CAST((ts - ?) / ? AS INTEGER) AS b, COUNT(*) AS req,
|
||||||
|
COUNT(*) - COALESCE(SUM(ok), 0) AS err,
|
||||||
|
AVG(CASE WHEN ok = 1 THEN latency_ms END) AS avgms,
|
||||||
|
COALESCE(SUM(prompt_tokens), 0) AS pt, COALESCE(SUM(eval_tokens), 0) AS et
|
||||||
|
FROM ollama_stats WHERE node_id = ? AND ts >= ? GROUP BY b ORDER BY b ASC`,
|
||||||
|
from, bucketMs, nodeId, from,
|
||||||
|
);
|
||||||
|
const buckets: StatsBucket[] = Array.from({ length: BUCKETS }, (_, i) => ({
|
||||||
|
t: from + i * bucketMs,
|
||||||
|
req: 0,
|
||||||
|
err: 0,
|
||||||
|
avgMs: null,
|
||||||
|
promptTok: 0,
|
||||||
|
evalTok: 0,
|
||||||
|
}));
|
||||||
|
for (const r of rows) {
|
||||||
|
const b = Number(r.b);
|
||||||
|
if (!Number.isInteger(b) || b < 0 || b >= BUCKETS) continue;
|
||||||
|
const slot = buckets[b]!;
|
||||||
|
slot.req = Number(r.req);
|
||||||
|
slot.err = Number(r.err);
|
||||||
|
slot.avgMs = r.avgms === null ? null : Number(r.avgms);
|
||||||
|
slot.promptTok = Number(r.pt);
|
||||||
|
slot.evalTok = Number(r.et);
|
||||||
|
}
|
||||||
|
const totals = buckets.reduce(
|
||||||
|
(a, b) => ({ req: a.req + b.req, err: a.err + b.err, promptTok: a.promptTok + b.promptTok, evalTok: a.evalTok + b.evalTok }),
|
||||||
|
{ req: 0, err: 0, promptTok: 0, evalTok: 0 },
|
||||||
|
);
|
||||||
|
const latRows = buckets.filter((b) => b.avgMs !== null && b.req - b.err > 0);
|
||||||
|
const latWeight = latRows.reduce((s, b) => s + (b.req - b.err), 0);
|
||||||
|
const avgMs = latWeight > 0
|
||||||
|
? latRows.reduce((s, b) => s + b.avgMs! * (b.req - b.err), 0) / latWeight
|
||||||
|
: null;
|
||||||
|
// Tokens/s over the window's active span (first to last non-empty bucket).
|
||||||
|
const active = buckets.filter((b) => b.req > 0);
|
||||||
|
const activeMs = active.length > 1 ? active[active.length - 1]!.t - active[0]!.t + bucketMs : active.length === 1 ? bucketMs : 0;
|
||||||
|
const tokPerSec = activeMs > 0 ? (totals.promptTok + totals.evalTok) / (activeMs / 1000) : null;
|
||||||
|
return { window, from, to, totals: { ...totals, avgMs, tokPerSec }, buckets };
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { getConversation, getMessages, db } 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 async function initReportsTables(): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
await d.exec(`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 BIGINT 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 BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`);
|
||||||
|
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`);
|
||||||
|
if (d.kind === "sqlite") {
|
||||||
|
// Prompt snapshot added after the initial schema — keep idempotent.
|
||||||
|
const rcols = await d.all<{ name: string }>("PRAGMA table_info(reports)");
|
||||||
|
if (!rcols.some((c) => c.name === "prompt")) {
|
||||||
|
await d.exec("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
|
||||||
|
const ucols = await d.all<{ name: string }>("PRAGMA table_info(users)");
|
||||||
|
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Postgres: ADD COLUMN IF NOT EXISTS is natively idempotent.
|
||||||
|
await d.exec("ALTER TABLE reports ADD COLUMN IF NOT EXISTS prompt TEXT NOT NULL DEFAULT ''");
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS report_shadowbanned INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max non-terminal reports per reporter (spam guard). */
|
||||||
|
export const MAX_OPEN_REPORTS = 20;
|
||||||
|
|
||||||
|
/** Still-triageable report already covering this message, if any. */
|
||||||
|
export async function findOpenReport(conversationId: string, messageIndex: number): Promise<Report | null> {
|
||||||
|
if (!conversationId || messageIndex < 0) return null;
|
||||||
|
return db().get<Report>(
|
||||||
|
"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",
|
||||||
|
conversationId, messageIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReport(opts: {
|
||||||
|
reporterId: string;
|
||||||
|
reporterName: string;
|
||||||
|
conversationId: string;
|
||||||
|
messageIndex: number;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
clientContent: string;
|
||||||
|
clientPrompt: string;
|
||||||
|
clientModel: string;
|
||||||
|
}): Promise<Report> {
|
||||||
|
const dup = opts.conversationId && opts.messageIndex >= 0
|
||||||
|
? await 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 = await db().get<{ n: number }>(
|
||||||
|
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
|
||||||
|
opts.reporterId,
|
||||||
|
);
|
||||||
|
if ((open?.n ?? 0) >= 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 = await getConversation(opts.conversationId);
|
||||||
|
if (conv && conv.user_id === opts.reporterId) {
|
||||||
|
if (conv.model) model = conv.model;
|
||||||
|
const msgs = (await 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,
|
||||||
|
};
|
||||||
|
await db().run(
|
||||||
|
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
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 async function listReports(): Promise<Report[]> {
|
||||||
|
return db().all<Report>(
|
||||||
|
"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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReport(id: string): Promise<Report | null> {
|
||||||
|
return db().get<Report>(
|
||||||
|
"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 = ?",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReportStatus(id: string, status: ReportStatus, adminNote: string): Promise<Report | null> {
|
||||||
|
await db().run("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?",
|
||||||
|
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 async function setReportShadowbanned(userId: string, shadowbanned: boolean): Promise<number> {
|
||||||
|
await db().run("UPDATE users SET report_shadowbanned = ? WHERE id = ?", shadowbanned ? 1 : 0, userId);
|
||||||
|
const row = await db().get<{ v: number }>("SELECT report_shadowbanned AS v FROM users WHERE id = ?", userId);
|
||||||
|
return row?.v ?? 0;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return (this.available ?? false) && (await isOcrToolEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
async unavailableReason(): Promise<string | null> {
|
||||||
|
if (!(this.available ?? false)) {
|
||||||
|
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
|
||||||
|
}
|
||||||
|
if (!(await isOcrToolEnabled())) return "OCR is disabled by the administrator (Admin Center → Features).";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
|
||||||
|
if (!(await this.isAvailable())) throw new Error((await 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 = sanitizeOcrText(out);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unrecognizable output from the OCR engine while preserving every
|
||||||
|
* legitimate character (accents, CJK, punctuation, emoji…).
|
||||||
|
*
|
||||||
|
* Tesseract emits raw control bytes, Unicode private-use / unassigned /
|
||||||
|
* surrogate code points and U+FFFD replacement chars when glyphs are
|
||||||
|
* unreadable, plus form-feeds between pages and \r line endings. Those
|
||||||
|
* poison the model prompt and the attachment preview, so they are removed
|
||||||
|
* here. Visible-but-dubious glyphs (|, ~, …) are kept: they may be real
|
||||||
|
* content and only the user can judge them in the review step.
|
||||||
|
*/
|
||||||
|
export function sanitizeOcrText(raw: string): string {
|
||||||
|
// Normalize line endings first; form-feeds are page breaks, not content.
|
||||||
|
let text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n");
|
||||||
|
// Strip BOMs / zero-width no-break spaces anywhere (keep ZWJ + VS16 so
|
||||||
|
// emoji sequences survive).
|
||||||
|
text = text.replace(/[\uFEFF\u200B\u200C\u2060\u180E]/g, "");
|
||||||
|
let out = "";
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === "\n" || ch === "\t" || ch === "\u200D" || ch === "\uFE0F") {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cp = ch.codePointAt(0)!;
|
||||||
|
// U+FFFD (replacement char) and U+FFFE/FFFF are the engine's markers
|
||||||
|
// for unreadable glyphs — never real content.
|
||||||
|
if (cp === 0xfffd || cp === 0xfffe || cp === 0xffff) continue;
|
||||||
|
// Keep letters, marks, numbers, punctuation, symbols and spaces.
|
||||||
|
// Drops Cc/Cf/Cs/Co/Cn (controls, formats, surrogates, private-use,
|
||||||
|
// unassigned) and Zl/Zp separators.
|
||||||
|
if (/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]$/u.test(ch)) {
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
// Anything else is unrecognizable — drop it.
|
||||||
|
}
|
||||||
|
// Tidy whitespace without touching indentation: trim line ends, collapse
|
||||||
|
// 3+ blank lines, drop leading/trailing blank lines.
|
||||||
|
out = out
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/[ \t\u00A0]+$/g, ""))
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OCR_MAX_BYTES };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { OcrTool } from "./ocr.ts";
|
||||||
|
import { TtsTool } from "./tts.ts";
|
||||||
|
import type { PlatformTool } from "./types.ts";
|
||||||
|
|
||||||
|
/** Platform tools registry (upload flows always go through tools). */
|
||||||
|
class ToolRegistry {
|
||||||
|
readonly ocr = new OcrTool();
|
||||||
|
readonly tts = new TtsTool();
|
||||||
|
private tools: PlatformTool[] = [this.ocr, this.tts];
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await this.ocr.init();
|
||||||
|
await this.tts.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
|
||||||
|
return Promise.all(
|
||||||
|
this.tools.map(async (t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
available: await t.isAvailable(),
|
||||||
|
reason: await t.unavailableReason(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools = new ToolRegistry();
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
import { isTtsToolEnabled } from "../db.ts";
|
||||||
|
import { driverLog, fmtBytes, fmtMs } from "../drivers/log.ts";
|
||||||
|
import type { PlatformTool } from "./types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neural TTS tool: turns assistant replies / preview lines into natural
|
||||||
|
* speech (far beyond the browser's robotic speechSynthesis).
|
||||||
|
*
|
||||||
|
* Providers, tried in order (first configured wins; `TTS_PROVIDER` forces
|
||||||
|
* one explicitly):
|
||||||
|
* - elevenlabs (ELEVENLABS_API_KEY [+ ELEVENLABS_VOICE]) — most natural,
|
||||||
|
* any voice_id (20-char ids, clones included).
|
||||||
|
* - openai (OPENAI_API_KEY, reuses the LLM provider key) — tts-1 with the
|
||||||
|
* 6 stock voices (alloy/echo/fable/onyx/nova/shimmer).
|
||||||
|
* - piper (PIPER_BIN + PIPER_MODEL, local binary like tesseract) — offline,
|
||||||
|
* single configured voice, WAV output.
|
||||||
|
*
|
||||||
|
* Output is cached on disk (DATA_DIR/tts-cache, sha of provider|voice|text,
|
||||||
|
* 200 files cap) so repeated lines cost nothing. Without any provider the
|
||||||
|
* tool reports unavailable and the frontend falls back to browser speech.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TtsProvider = "elevenlabs" | "openai" | "piper";
|
||||||
|
|
||||||
|
export const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const;
|
||||||
|
|
||||||
|
const TTS_MAX_CHARS = 2000;
|
||||||
|
const TTS_TIMEOUT_MS = 60_000;
|
||||||
|
const TTS_CACHE_CAP = 200;
|
||||||
|
|
||||||
|
// Abuse protection: neural TTS costs money per byte.
|
||||||
|
const TTS_WINDOW_MS = 3600_000;
|
||||||
|
const TTS_MAX_JOBS = 30;
|
||||||
|
const ttsJobs = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function ttsAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const list = (ttsJobs.get(userId) ?? []).filter((t) => t > now - TTS_WINDOW_MS);
|
||||||
|
ttsJobs.set(userId, list);
|
||||||
|
if (list.length >= TTS_MAX_JOBS) {
|
||||||
|
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + TTS_WINDOW_MS - now) / 1000)) };
|
||||||
|
}
|
||||||
|
return { ok: true, retryAfterSec: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordTtsJob(userId: string): void {
|
||||||
|
const list = ttsJobs.get(userId) ?? [];
|
||||||
|
list.push(Date.now());
|
||||||
|
ttsJobs.set(userId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevenlabsBase(): string {
|
||||||
|
return (process.env["ELEVENLABS_BASE_URL"] || "https://api.elevenlabs.io").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openaiBase(): string {
|
||||||
|
return (process.env["OPENAI_BASE_URL"] || "https://api.openai.com").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function piperBin(): string {
|
||||||
|
return process.env["PIPER_BIN"] || "piper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsVoice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function piperAvailable(): Promise<boolean> {
|
||||||
|
if (!config.piperModel) return false;
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn([piperBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const code = await proc.exited;
|
||||||
|
return code === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which provider would serve right now (null = none configured). */
|
||||||
|
export async function activeTtsProvider(): Promise<TtsProvider | null> {
|
||||||
|
const forced = (process.env["TTS_PROVIDER"] || "auto").toLowerCase();
|
||||||
|
const eleven = config.elevenlabsApiKey.length > 0;
|
||||||
|
const openai = config.openaiApiKey.length > 0;
|
||||||
|
const piper = await piperAvailable();
|
||||||
|
const pick = (p: TtsProvider): TtsProvider | null =>
|
||||||
|
p === "elevenlabs" ? (eleven ? p : null) : p === "openai" ? (openai ? p : null) : piper ? p : null;
|
||||||
|
if (forced === "elevenlabs" || forced === "openai" || forced === "piper") return pick(forced);
|
||||||
|
return pick("elevenlabs") ?? pick("openai") ?? pick("piper");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeTtsVoice(provider: TtsProvider, raw: unknown): string | null {
|
||||||
|
if (raw === undefined || raw === null || raw === "") return null;
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const v = raw.trim();
|
||||||
|
if (provider === "openai") {
|
||||||
|
return (OPENAI_VOICES as readonly string[]).includes(v.toLowerCase()) ? v.toLowerCase() : null;
|
||||||
|
}
|
||||||
|
if (provider === "elevenlabs") {
|
||||||
|
return /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;
|
||||||
|
}
|
||||||
|
return null; // piper: single configured model voice
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheDir(): string {
|
||||||
|
const dir = join(config.dataDir, "tts-cache");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(provider: TtsProvider, voice: string, text: string): string {
|
||||||
|
return createHash("sha256").update(`${provider}|${voice}|${text}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedPath(key: string, ext: string): string {
|
||||||
|
return join(cacheDir(), `${key}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneCache(): void {
|
||||||
|
try {
|
||||||
|
const dir = cacheDir();
|
||||||
|
const files = readdirSync(dir)
|
||||||
|
.map((f) => {
|
||||||
|
try {
|
||||||
|
return { f, mtime: statSync(join(dir, f)).mtimeMs };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((x): x is { f: string; mtime: number } => x !== null)
|
||||||
|
.sort((a, b) => a.mtime - b.mtime);
|
||||||
|
for (const old of files.slice(0, Math.max(0, files.length - TTS_CACHE_CAP))) {
|
||||||
|
try {
|
||||||
|
rmSync(join(dir, old.f));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cache must never break synthesis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElevenLabs voice catalog (cached 5 min, best-effort — drives the admin
|
||||||
|
// voice select menus; never blocks synthesis when unreachable).
|
||||||
|
let elVoicesCache: { at: number; voices: TtsVoice[] } | null = null;
|
||||||
|
|
||||||
|
export async function elevenlabsVoices(): Promise<TtsVoice[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (elVoicesCache && now - elVoicesCache.at < 300_000) return elVoicesCache.voices;
|
||||||
|
const fallback = config.elevenlabsVoice ? [{ id: config.elevenlabsVoice, name: config.elevenlabsVoice }] : [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${elevenlabsBase()}/v1/voices`, {
|
||||||
|
headers: { "xi-api-key": config.elevenlabsApiKey },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
const data = (await res.json()) as { voices?: { voice_id?: unknown; name?: unknown }[] };
|
||||||
|
const voices = (data.voices ?? [])
|
||||||
|
.filter((v) => typeof v.voice_id === "string" && typeof v.name === "string")
|
||||||
|
.map((v) => ({ id: v.voice_id as string, name: v.name as string }));
|
||||||
|
if (voices.length === 0) return fallback;
|
||||||
|
elVoicesCache = { at: now, voices };
|
||||||
|
return [...voices, ...fallback.filter((f) => !voices.some((v) => v.id === f.id))];
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthElevenlabs(text: string, voiceId: string): Promise<{ audio: Uint8Array; contentType: string }> { const res = await fetch(`${elevenlabsBase()}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "audio/mpeg",
|
||||||
|
"xi-api-key": config.elevenlabsApiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text, model_id: "eleven_multilingual_v2" }),
|
||||||
|
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`elevenlabs tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthOpenai(text: string, voice: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||||
|
const res = await fetch(`${openaiBase()}/v1/audio/speech`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.openaiApiKey}` },
|
||||||
|
body: JSON.stringify({ model: "tts-1", input: text, voice, response_format: "mp3" }),
|
||||||
|
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => "");
|
||||||
|
throw new Error(`openai tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthPiper(text: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||||
|
const out = join(cacheDir(), `piper-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}.wav`);
|
||||||
|
let proc;
|
||||||
|
try {
|
||||||
|
proc = Bun.spawn([piperBin(), "--model", config.piperModel, "--output_file", out], {
|
||||||
|
stdin: new TextEncoder().encode(text),
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`tts engine failed to start: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
// already exited
|
||||||
|
}
|
||||||
|
}, TTS_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const [code, errText] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
||||||
|
if (code !== 0) throw new Error(`piper failed (exit ${code}): ${errText.trim().slice(0, 200)}`);
|
||||||
|
const file = Bun.file(out);
|
||||||
|
if (!(await file.exists())) throw new Error("piper produced no output");
|
||||||
|
return { audio: new Uint8Array(await file.arrayBuffer()), contentType: "audio/wav" };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
rmSync(out);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TtsTool implements PlatformTool {
|
||||||
|
readonly name = "tts";
|
||||||
|
readonly description = "Neural text-to-speech (ElevenLabs / OpenAI / local piper) for natural voice playback.";
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const p = await activeTtsProvider();
|
||||||
|
driverLog("tools", `tts engine: ${p ?? "none configured — browser speech fallback only"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return (await activeTtsProvider()) !== null && (await isTtsToolEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
async unavailableReason(): Promise<string | null> {
|
||||||
|
if ((await activeTtsProvider()) === null) {
|
||||||
|
return "no TTS provider configured (ELEVENLABS_API_KEY, OPENAI_API_KEY or PIPER_BIN+PIPER_MODEL). Browser speech is used instead.";
|
||||||
|
}
|
||||||
|
if (!(await isTtsToolEnabled())) return "TTS is disabled by the administrator (Admin Center → Features).";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async providerInfo(): Promise<{ provider: TtsProvider | null; voices: TtsVoice[] }> {
|
||||||
|
const p = await activeTtsProvider();
|
||||||
|
if (!p) return { provider: null, voices: [] };
|
||||||
|
if (p === "openai") return { provider: p, voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) };
|
||||||
|
if (p === "elevenlabs") return { provider: p, voices: await elevenlabsVoices() };
|
||||||
|
return { provider: p, voices: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultVoice(provider: TtsProvider): string {
|
||||||
|
if (provider === "openai") {
|
||||||
|
const v = sanitizeTtsVoice("openai", config.ttsVoice);
|
||||||
|
return v ?? "alloy";
|
||||||
|
}
|
||||||
|
if (provider === "elevenlabs") return config.elevenlabsVoice || "pNInz6obpgDQGcFmaJgB";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(text: string, voice?: string): Promise<{ audio: Uint8Array; contentType: string; provider: TtsProvider; fromCache: boolean }> {
|
||||||
|
const provider = await activeTtsProvider();
|
||||||
|
if (!provider) throw new Error((await this.unavailableReason()) ?? "tts unavailable");
|
||||||
|
if (!(await isTtsToolEnabled())) throw new Error("tts disabled");
|
||||||
|
const clean = text.trim().slice(0, TTS_MAX_CHARS);
|
||||||
|
if (!clean) throw new Error("empty text");
|
||||||
|
const resolved = sanitizeTtsVoice(provider, voice) ?? this.defaultVoice(provider);
|
||||||
|
const ext = provider === "piper" ? "wav" : "mp3";
|
||||||
|
const key = cacheKey(provider, resolved, clean);
|
||||||
|
const hit = Bun.file(cachedPath(key, ext));
|
||||||
|
if (await hit.exists()) {
|
||||||
|
return {
|
||||||
|
audio: new Uint8Array(await hit.arrayBuffer()),
|
||||||
|
contentType: provider === "piper" ? "audio/wav" : "audio/mpeg",
|
||||||
|
provider,
|
||||||
|
fromCache: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = provider === "elevenlabs"
|
||||||
|
? await synthElevenlabs(clean, resolved)
|
||||||
|
: provider === "openai"
|
||||||
|
? await synthOpenai(clean, resolved)
|
||||||
|
: await synthPiper(clean);
|
||||||
|
await Bun.write(cachedPath(key, ext), out.audio);
|
||||||
|
pruneCache();
|
||||||
|
driverLog("tools", `tts ${provider} voice=${resolved || "default"}: ${fmtBytes(out.audio.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
return { ...out, provider, fromCache: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TTS_MAX_CHARS };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Platform tool: a server-side capability the frontend invokes explicitly
|
||||||
|
* (uploads always go through tools — never raw to the model). Availability
|
||||||
|
* is async: feature flags live in the database now. */
|
||||||
|
export interface PlatformTool {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
unavailableReason(): Promise<string | null>;
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||||
|
import { db } 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 async function initTotpTables(): Promise<void> {
|
||||||
|
const d = db();
|
||||||
|
if (d.kind === "sqlite") {
|
||||||
|
try {
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
|
} catch {
|
||||||
|
// column already exists — fine
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
await d.exec(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function getTotpSecret(userId: string): Promise<string> {
|
||||||
|
const row = await db().get<{ s: string }>("SELECT totp_secret AS s FROM users WHERE id = ?", userId);
|
||||||
|
return row?.s ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTotpSecret(userId: string, secret: string): Promise<void> {
|
||||||
|
await db().run("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short-lived login challenge after a correct key; returns the raw token. */
|
||||||
|
export async function createTotpChallenge(userId: string): Promise<string> {
|
||||||
|
const token = "tc_" + randomBytes(24).toString("base64url");
|
||||||
|
const hash = createHash("sha256").update(token).digest("hex");
|
||||||
|
const now = Date.now();
|
||||||
|
await db().run(
|
||||||
|
"INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||||
|
hash, userId, now, now + CHALLENGE_TTL_MS,
|
||||||
|
);
|
||||||
|
// Opportunistic cleanup.
|
||||||
|
await db().run("DELETE FROM totp_challenges WHERE expires_at < ?", now);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeTotpChallenge(token: string): Promise<string | null> {
|
||||||
|
const hash = createHash("sha256").update(token).digest("hex");
|
||||||
|
const row = await db().get<{ user_id: string; expires_at: number }>(
|
||||||
|
"SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?", hash,
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
await db().run("DELETE FROM totp_challenges WHERE token_hash = ?", hash);
|
||||||
|
if (row.expires_at < Date.now()) return null;
|
||||||
|
return row.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Housekeeping for account deletion (no other caller needs it). */
|
||||||
|
export async function deleteTotpChallenges(userId: string): Promise<void> {
|
||||||
|
await db().run("DELETE FROM totp_challenges WHERE user_id = ?", userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { db } 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 server-side (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 async function getUserProviderKey(userId: string, provider: UserProviderId): Promise<string | null> {
|
||||||
|
const r = await db().get<{ api_key: string }>(
|
||||||
|
"SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?",
|
||||||
|
userId, provider,
|
||||||
|
);
|
||||||
|
return r?.api_key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserProviders(userId: string): Promise<UserProviderStatus[]> {
|
||||||
|
const rows = await db().all<{ provider: string; api_key: string; updated_at: number }>(
|
||||||
|
"SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
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 async function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): Promise<void> {
|
||||||
|
await db().run(
|
||||||
|
"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",
|
||||||
|
userId, provider, apiKey, Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserProviderKey(userId: string, provider: UserProviderId): Promise<void> {
|
||||||
|
await db().run("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?", 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. Every entry is marked
|
||||||
|
* `personal: true` so the UI can list them in their own menu section.
|
||||||
|
* 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 = await getUserProviderKey(userId, p);
|
||||||
|
if (!key) return;
|
||||||
|
try {
|
||||||
|
for (const m of await buildUserProviderDriver(p, key).listModels()) {
|
||||||
|
out.push({ ...m, personal: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[providers] user ${p} listModels failed:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(jobs);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user