Compare commits

..
1 Commits
Author SHA1 Message Date
k 5a666efea1 first commit 2026-09-21 09:47:07 +02:00
70 changed files with 0 additions and 16714 deletions
-64
View File
@@ -1,64 +0,0 @@
# --- server ---
# development locally; the NixOS module sets production (gates DRIVER_DEBUG).
NODE_ENV=development
PORT=3000
HOST=127.0.0.1
# ADMIN access key (plain). This is the admin account's key — it manages all
# other No-KYC accounts from the web UI (Accounts panel).
# Generate: openssl rand -base64 24
APP_PASSWORD=change-me
# Session TTL in hours (Bearer token stored in localStorage, no cookies)
SESSION_TTL_HOURS=720
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
DATA_DIR=./data
# Local file CDN (avatars). NixOS module points it under the state dir.
CDN_DIR=./cdn
# Local file CDN (avatars today, more namespaces tomorrow).
# Dev default is a folder next to the codebase; NixOS points it under state dir.
CDN_DIR=./cdn
# --- platform tools: OCR (tesseract binary) ---
# TESSERACT_BIN=/nix/store/…-tesseract-5.5.3/bin/tesseract
OCR_LANG=eng
OCR_MAX_CHARS=100000
# Per-driver debug logging to stdout ([driver:name] lines).
DRIVER_DEBUG=true
# Public docs wiki (Gitea wiki pages of this repo). GET /wiki redirects here.
WIKI_URL=https://git.kisakay.com/k/chat/wiki
# --- key recovery via email (optional — empty SMTP_HOST disables it) ---
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=KisAssistant <[email protected]>
APP_URL=http://localhost:3000
RESET_TTL_MIN=60
# --- rate limit on /api/auth/login (per IP, sliding window) ---
RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MIN=10
# --- drivers ---
# Priority (global): ollama -> mistral -> glm -> puppeteer-openai(stub) -> 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
# Puppeteer driver is a stub on purpose (fragile ToS-risky). Never enabled for now.
PUPPETEER_ENABLED=false
-11
View File
@@ -1,11 +0,0 @@
/data/
/cdn/
node_modules/
dist/
/public/
.env
*.log
*.db
*.db-journal
*.db-wal
*.db-shm
-54
View File
@@ -1,54 +0,0 @@
# AGENTS.md — instructions for coding agents working on KisAssistant
Read this before touching the code. Docs live in `docs/` (English).
## What this is
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
frontend, SQLite persistence via Bun's native `bun:sqlite` driver.
## Commands (run from repo root unless noted)
- `bun install` — backend deps (almost none; `@types/bun` only)
- `bun run dev` — backend with **watch mode** (auto-restart on edit), http://localhost:3000
- `bun run dev:frontend` — Vite dev server with HMR + `/api` proxy, http://localhost:5173
- `bun run dev:all` — both at once (two terminals also works)
- `bun run build:frontend` / `bun --cwd frontend build` — typecheck + Vite build into `public/` (wipes it first)
- `bun run typecheck` / `bun run typecheck:frontend` — `tsc --noEmit` for each side
- API smoke tests: boot on a **non-3000 port** with throwaway `DATA_DIR`, drive with curl/python (see `docs/OPERATIONS.md`). Port 3000 is the owner's dev server — **never kill it, never bind it in tests**.
## Layout
```
src/index.ts HTTP routes (Bun.serve). Auth gate: everything under /api/ except /api/auth/login and /api/share/:id
src/config.ts env parsing (PORT, HOST, APP_PASSWORD=admin key, OLLAMA_HOST, DATA_DIR, rate limits)
src/db.ts SQLite store: users, sessions, conversations, messages, shares (+ admin row bootstrap)
src/auth.ts login(username,key), hashed Bearer sessions, sliding-window login rate limit
src/static.ts static file server + SPA fallback (covers /share/:id)
src/drivers/ LLMDriver interface, OllamaDriver, ApiDriverBase, Mistral/DeepSeek/Anthropic/OpenAI, puppeteer stub, DriverRegistry
frontend/src/ App.tsx (routing+state), lib/api.ts (fetch wrapper), components/
public/ BUILD OUTPUT (Vite). Never hand-edit; rebuild instead. Not committed (see .gitignore)
flake.nix/module.nix NixOS packaging + service module
```
## Auth model (do not weaken)
- No cookies. Bearer tokens in `localStorage`, stored **sha256-hashed** in SQLite.
- `admin` logs in with `APP_PASSWORD` from `.env`. Users log in with admin-issued `ka_…` keys (also hashed). Comparisons are timing-safe.
- Conversations/shares are per-account; always enforce `conv.user_id === user.id`. Admin has no cross-account read.
- Never log, print, or commit secrets. `.env`, `data/`, `*.db` are gitignored.
## Conventions
- Language: TypeScript strict. UI text: English only.
- Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN).
- Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework).
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The puppeteer scraper stays a stub (ToS risk).
- 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.
-140
View File
@@ -1,140 +0,0 @@
# KisAssistant (kisa's assistant)
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
Signal-style, Inter font, light/dark/auto themes), Bun backend with
class-oriented LLM **drivers** and a **SQLite** database (Bun native driver) for
accounts, server-side chats and public shares. No cookies — Bearer keys in
`localStorage`.
Driver priority:
```
1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (stub, not implemented)
→ 5. deepseek → 6. anthropic → 7. openai
```
Docs: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (backend, drivers, DB) ·
[`docs/FRONTEND.md`](docs/FRONTEND.md) (components, theming) ·
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) (env, dev, accounts, NixOS) ·
[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md) (server hosting tutorial).
Agent instructions: [`AGENTS.md`](AGENTS.md).
## Quick start
Requirements: [Bun](https://bun.sh) ≥ 1.1, Node 20+ (frontend build), Ollama
reachable at `OLLAMA_HOST` (default `http://10.66.66.4:11434`).
```bash
cp .env.example .env
# edit .env → set APP_PASSWORD (openssl rand -base64 24), check OLLAMA_HOST
bun install
bun run --cwd frontend build # builds React app into public/
bun run dev # http://localhost:3000 (or `bun run start`)
# frontend dev with proxy: bun run --cwd frontend dev # http://localhost:5173
```
Log in as `admin` with the `APP_PASSWORD` key, then create No-KYC user accounts
from the **Accounts** panel (each gets a one-time access key to hand over).
## Accounts (No-KYC, admin-managed)
- `admin` authenticates with `APP_PASSWORD` from `.env`.
- Admin creates users (`username`, display name, avatar URL, theme) — the API
returns a `ka_…` access key **once**; keys are stored hashed (sha256).
- Users log in with `{ username, key }`. Regenerating a key revokes old sessions.
- Per-account: display name, avatar (uploaded to the built-in CDN from profile
settings), theme (`auto`/`light`/`dark`), accent color, feature flags.
## API
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | `/api/auth/login` | rate-limited (5/10min/IP) | `{username, key}` → `{token, expiresAt, user}` |
| POST | `/api/auth/register` | public, rate-limited | self-registration (403 when disabled by admin) → `{user, key}` |
| GET | `/api/auth/verify` | Bearer | → `{ok, user}` |
| POST | `/api/auth/logout` | Bearer | revokes token |
| GET/PATCH | `/api/me` | Bearer | profile: displayName, avatarUrl, theme |
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
| POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) |
| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model |
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply |
| 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/share/:publicId` | **public** | read-only shared chat JSON |
| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers |
| POST | `/api/chat` | Bearer | `{model, messages, conversationId?, stream?}` → JSON or SSE |
`POST /api/chat` with `conversationId` persists the user message + assistant
reply into that conversation (ownership enforced) and auto-titles new chats.
## Database (SQLite, Bun native)
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username,
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
`conversations` (id, user, title, topic, model), `messages`, `shares`,
`resets` (recovery tokens), `settings` (feature flags)
(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 drivers
src/drivers/puppeteer.ts PuppeteerOpenAIDriver — stub (always disabled)
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
src/db.ts SQLite store (users, sessions, convs, messages, shares)
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
```
Enable an API driver later: `MISTRAL_ENABLED=true` + `MISTRAL_API_KEY=…`, restart.
## NixOS hosting (chat.kisakay.com)
Full tutorial: **[`docs/NIXOS-HOSTING.md`](docs/NIXOS-HOSTING.md)**.
Flake exposes `packages.<system>.default` (backend + prebuilt frontend) and
`nixosModules.default`. The frontend is built offline inside Nix with Bun from
`frontend/bun.lock`; on a deps hash mismatch, paste the `got: sha256-…` value
Nix reports into `bunDepsHash` in `flake.nix`.
```nix
kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
package = kisassistant.packages.${pkgs.system}.default; # required
enableNginx = true; # nginx reverse proxy + ACME
domain = "chat.kisakay.com"; # vhost name (required with enableNginx)
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434";
};
security.acme.acceptTerms = true; # required for the TLS certificate
```
Nginx terminates TLS; the app listens on `127.0.0.1:3000` with
`NODE_ENV=production`. SQLite lives in `/var/lib/kisassistant/data`, avatars
(file CDN) in `/var/lib/kisassistant/cdn`.
## Frontend
`frontend/` — React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/gfm,
Inter Variable (self-hosted, no external requests). `bun run --cwd frontend build`
outputs to `public/` (wiped first). Collapsible sidebar, right-click context menu
(rename / topic / share / delete), share modals, confirm dialogs, admin accounts
panel, profile settings, public read-only `/share/:id` page. English only.
-21
View File
@@ -1,21 +0,0 @@
--- VERY HIGH PRIORITY:
Update pfp composer when new upload.actually i have to refresh the page after the save button for seeing it
when upload did we have to refresh the pfp preview composer to the new uploaded pictures.
--- MEDIUM PRIORITY
Being abble to :
Disable features / Enable it:
ie : thinking, deep search, etc...
Downloading models throught a catalog : ollama only
--- LOW PRIORITY
Search into the old prompts with a search bar.
integrate the platform name in the top of the conversations slide list. with a well beautiful font and a flower in lucide svg icon for the brand icon
Make a beautiful SEO integrate into the html file for disovering.
make a robots.txt file for let all llm/crawler/scrapper visiting the site.
-60
View File
@@ -1,60 +0,0 @@
--- VERY HIGH PRIORITY
fait en sorte de pouvoir upload un avatar (Servir un cdn en local via fichier upload sur le serveur dans un dossier à côté de la codebase)
cdn comme sa
http<remote>/cdn/<avatar>/<id of the account>.<extension>
pouvoir gérer dans le code proprement les routes cdn . FU (futur usage/futurproof)
http<?s>://<root host>/cdn/<x>/<key>.<fileTypeAllowed>
Extension authorisé ONLY:
jpg/jpeg/webp/png
BLACKLISTE SYSTÈMATHIQUEMENT LE RESTE.
Pas que un detect typeFile avec l'extension.
Logique propre de détection du fichier *magic byte, looking bytes, etc*
Changer l 'avatar à plusieurs règles:
Rate-limit per change / per account de:
5 fois maximum en 2 heures
(window time: 2 hours, max count 5)
file length maxi: 5MB
SYSTEM DE CHANGEMENT DE MDP:
Donc avoir une upload box, propre, adapative etc
---- MEDIUM PRIORITY
ADAPTATION MOBILE:
GÉRER LES MODALS, LES BOXES, LES BOARDING, LES CHAT BOXES AUSSI POUR PETIT ÉCRAN, ADAPTER LEUR WEBVIEW PORTAGE, DIALOG BOX, CONFIRMATON, TRAY CHANNELS SIDE LIST...
DRAG AND SNAP LIKE PHONES, SCROLLING FOR OPENGING/EXITING ETC..
--- LOW PRIORITY
Système de service mails aussi pour les comptes (optionel) pour recovery passwords, dans le page de login:
- Recover password with emails. smtp server,
--- NEW NEW BUG:
when uploading new pfp, actually it uploading the file without click needing into save button.
the normal flow that i want is. uploading , updating the preview, cancel button: keep the current one, re-freshing the previw.
save -> saving and pushing the pfp to the api
--- bugs:
when disabling attachment sendability: the text "Message" placeholder nto the composer chat are too tigh to the left border, make a fallback padding when no attachment button
make in the settigns: an sub category: changing key, admiting TOTP key/passkey for security
--- bugd II:
sur la page share, l' avatar de l'utilisateur ne s'affiche pas -> fix sa
--- improvements:
éviter de ask h24 ollama pour ces modèles quand un utilisateur ce connece. c'est le backend qui sert les demande d el'utilisateurs. iL relay pas sur le ollama. Le backend vas cacher avec un TLL les modèles présent en mémire dans un objet qui est dans le backend. refresh avec un TT: toutes les minutes.
L'administrateur lui, qand il demande les modèles dams le ollama, lui, force un refresh .
--- ADMIN CENTER: SMTP CONNECTIVITY.
Make a SMTP tester in the admin center, and also a viewer of the smtp credentials (default bluring the smtp pasword OBVIOUSLY) And also a SMTP tester , sending a mail to a provided one , to tets the SMTP connectivity
--- ARCHIVED FEATURES:
a way to archive conversaitons with the contetxual menue.
When conversations are archived. it's impossible to write in . only read-only. also, when conversations are archived, they are not anymore visible in the conversations side list. They are still unarchivable and checkable, in a new sub-category options into the settigns modal : "Archiveds Chats"
-56
View File
@@ -1,56 +0,0 @@
---- 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
-29
View File
@@ -1,29 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant",
"dependencies": {
"nodemailer": "^10.0.10",
},
"devDependencies": {
"@types/bun": "latest",
"@types/nodemailer": "^8.0.2",
},
},
},
"packages": {
"@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=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
}
}
-116
View File
@@ -1,116 +0,0 @@
# Architecture (backend)
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
frontend (same origin, no CORS). Persistence is SQLite via Bun's native
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode).
```
browser ──► Bun.serve (src/index.ts)
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db
│ └─────► DriverRegistry ──► ollama / APIs
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id)
```
## Request flow: chat
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
1. Bearer token → `verifyToken` (sha256 lookup in `sessions`, expiry check).
2. `DriverRegistry.splitId` routes to the owning driver (unknown/disabled → 400).
3. If `conversationId` is set: conversation must belong to the caller, the new
user message is stored (deduped against the last stored message), new chats
are auto-titled from it.
4. Non-streaming → `driver.chat()` → JSON. Streaming → `driver.chatStream()`
→ SSE events `token` / `done` / `error`. The full reply is persisted after a
successful stream; on error the partial text is kept as-is (no internal
markers). Server logs each stream open/done/abort per driver when
`DRIVER_DEBUG=true`.
Streaming guarantees (ChatGPT-style, no buffering):
- Tokens are forwarded via `controller.enqueue` the moment they arrive from the
LLM; disconnects are detected (`req.signal`, guarded `enqueue`,
stream `cancel()`) and propagated upstream via an `AbortController` passed as
`opts.signal`, so the model stops generating when the tab closes.
- `Bun.serve` runs with `idleTimeout: 0` — the default 10 s would kill slow
streams (cold model load, thinking pauses) mid-generation.
- Responses carry `X-Accel-Buffering: no` and the NixOS nginx vhost sets
`proxy_buffering off`, otherwise the proxy would buffer the whole reply.
## Database schema
Created idempotently at boot in `getDb()` (no migration framework; keep it so).
| Table | Purpose |
|---|---|
| `users` | `id, username UNIQUE, display_name, avatar_url, theme, email, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). |
| `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). |
| `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. |
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
User deletion cascades manually in `deleteUser()` (messages → shares →
conversations → sessions → user) inside a transaction.
## Auth
- `POST /api/auth/login { username, key }`, sliding-window rate limit per IP
(`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN`, default 5 per 10 min) + 400 ms
delay on failure. Success clears the IP counter and issues a fresh token.
- `POST /api/auth/register { username, displayName?, email? }` — public
self-registration, guarded by the `registration_enabled` setting (403 when
off), same rate limit and validation as admin creation. Returns `{user, key}`
with the key shown once.
- `admin` is authenticated against `APP_PASSWORD` (timing-safe); users against
their `ka_…` access key (timing-safe against the stored hash).
- Keys are generated with `crypto.randomBytes` (`ka_` + 24 bytes base64url) and
**returned once** at creation/regeneration. Regeneration also wipes the
account's sessions.
## Local file CDN
`src/cdn.ts` + routes in `index.ts`. Generic namespaced layout
(`PUT /cdn/<ns>/<key>` authed, `GET /cdn/<ns>/<key>.<ext>` public) so future
file kinds plug into `CDN_NAMESPACES` without new routes. Today only `avatar`
exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
- Type safety by **magic bytes** (JPEG `FF D8 FF`, PNG signature, `RIFF…WEBP`);
only `jpg`/`png`/`webp` are representable, the stored extension is always
detected, everything else is `415`. Route regex charsets make traversal
unmatchable (attempts fall through to the SPA fallback).
- Per-namespace limits: `avatar` = 5 MB max (`413`), 5 uploads / 2 h /
account (`429` + `Retry-After`), counted on success. Re-upload replaces all
stored variants for the key.
- Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`,
`Cache-Control: public, max-age=3600`.
## Platform tools
`src/tools/`: `PlatformTool` interface + registry (today: `ocr` via the
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
`{ text, truncated, chars }` — transcription happens server-side with
per-driver-style stdout logging; a missing binary yields a clean 501. Text
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
only ever sees reviewed text blocks, never raw files.
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
`DriverRegistry` with global priority
`ollama → mistral → glm → puppeteer-openai (stub) → deepseek → anthropic → openai`.
Models are addressed as `"driver:model"` (split on the first `:`).
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
`POST /api/chat` with NDJSON streaming.
- `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.
- `PuppeteerOpenAIDriver`: intentional stub, always disabled (headless scraping
of chatgpt.com is fragile and against ToS).
-76
View File
@@ -1,76 +0,0 @@
# Frontend
React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/remark-gfm, Inter
Variable self-hosted (`@fontsource-variable/inter` — no external requests).
Built with `bun run build:frontend` into `public/` (Vite `emptyOutDir`; that
directory is a build artifact, never hand-edit, not committed).
## Routing
No router library — `App.tsx` matches `window.location.pathname` once (module
level, constant for the page lifetime): `/share/:publicId` renders the public
read-only `SharePage` (no auth), everything else renders the authenticated app.
## State & data flow
- `App.tsx` owns: `user`, `models`/`model`, `convs`, `activeId`, `messages`,
`streaming`, modal states. All server state is re-fetched (no client cache
library); conversations themselves live server-side in SQLite.
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
- Boot shows `LoadingScreen` for at least 2.5 s (`MIN_SPLASH_MS` in `App.tsx`)
so the bloom animation always plays, even when the session restores instantly.
- Sending: ensure a server conversation exists (created lazily on first send),
optimistic user message, stream tokens into `streaming`, then append the full
assistant message and refresh the list (server auto-titles new chats).
## Components (`components/`)
- `ui.tsx` — design system. `Button` (primary/secondary/ghost/danger),
`IconButton`, `Input`, `Field`, `Modal`, `ConfirmDialog`, `ContextMenu`,
`Avatar` (remote URL or gradient initials), `CopyButton`, `Spinner` and
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
**Never use a native `<select>`**; use `Picker` for model and theme choices.
- `Login.tsx` — username + access-key card.
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
(right-click or `···`: Rename / Set topic / Share publicly / Delete), user
chip (opens settings), Accounts button (admin), logout.
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
share button), message list (`Markdown` for assistant, emerald bubble for
user), rounded composer with paperclip attach menu, attachment chips
(Enter to send, Shift+Enter for newline).
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
copy, unshare), `SettingsModal` (display name, avatar upload box with
drag & drop, avatar URL, theme).
- `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, sharedAt, messages }`);
user messages render the author's avatar via `Avatar` (URL or initials).
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
a copy button.
## Theming
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
class from the account theme (`auto` follows `prefers-color-scheme` with a
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
everywhere; every surface needs a `dark:` variant. UI text is English only.
## Mobile
Below the `md` breakpoint the sidebar becomes a slide-in drawer (backdrop tap
or swipe-left closes it, hamburger in the chat header opens it). Modals render
as bottom sheets with a drag handle — pull down past the threshold to dismiss
(tap backdrop or Escape works too). Composer respects
`env(safe-area-inset-bottom)`. No separate mobile codebase: same components,
responsive Tailwind classes + minimal touch handlers.
-231
View File
@@ -1,231 +0,0 @@
# Hosting KisAssistant on a NixOS server
End-to-end guide: build the flake, wire the module into `configuration.nix`,
secure it with agenix/sops-nix, and put it behind nginx with automatic TLS.
Contents:
1. [Prerequisites](#1-prerequisites)
2. [Add the flake to your system inputs](#2-add-the-flake-to-your-system-inputs)
3. [Secrets (`APP_PASSWORD`, API keys)](#3-secrets-app_password-api-keys)
4. [Enable the service](#4-enable-the-service)
5. [TLS reverse proxy (`enableNginx`)](#5-tls-reverse-proxy-enablenginx)
6. [First deploy & smoke test](#6-first-deploy--smoke-test)
7. [Operations: logs, backups, updates](#7-operations-logs-backups-updates)
8. [Module option reference](#8-module-option-reference)
---
## 1. Prerequisites
- A NixOS machine (unstable channel or a flake lock close to it) with
` networking` + `firewall` under your control.
- Bun is **not** installed on the server — the flake wraps the runtime.
- DNS: an A/AAAA record for your domain (e.g. `chat.kisakay.com`) pointing at
the server. ACME (Let's Encrypt) needs port 80/443 reachable during
issuance.
- Ollama reachable from the server (e.g. `http://10.66.66.4:11434` on your
LAN/tailscale).
## 2. Add the flake to your system inputs
In your system flake (or `flake.nix` of your NixOS config):
```nix
{
inputs.kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
outputs = { self, nixpkgs, kisassistant, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
kisassistant.nixosModules.default
];
};
};
}
```
If you don't use flakes for your system config, import the module by path:
```nix
imports = [ /etc/nixos/kisassistant/module.nix ];
# and point the package at a build:
services.kisassistant.package =
(builtins.getFlake "git+http://git.kisakay.com/k/chat").packages.x86_64-linux.default;
```
> The frontend is compiled **offline inside the Nix build** with Bun from
> `frontend/bun.lock` (fixed-output deps fetch). If `nix build` ever fails with
> a `bunDepsHash` mismatch, paste the `got: sha256-…` value from the error into
> `bunDepsHash` in `flake.nix` and rebuild. This is normal after dependency
> bumps.
## 3. Secrets (`APP_PASSWORD`, API keys)
Never put secrets in the Nix store. Use [agenix](https://github.com/ryantm/agenix)
or [sops-nix](https://github.com/Mic92/sops-nix). Minimal agenix setup:
```bash
# on your machine, in the secrets repo of the host
npx agenix generate kisassistant-password -a $(readlink -f /etc/ssh/ssh_host_ed25519_key.pub)
# write the admin key into the file, e.g.:
echo "$(openssl rand -base64 24)" | agenix -e kisassistant-password.age # keep a copy!
```
Then on the host:
```nix
age.secrets.kisassistant-password.file = /etc/nixos/secrets/kisassistant-password.age;
```
The module consumes it as `passwordFile` and injects it into the service via
systemd `LoadCredential` — the file is read at runtime only.
## 4. Enable the service
```nix
services.kisassistant = {
enable = true;
# REQUIRED — the module refuses to evaluate without it.
package = kisassistant.packages.${pkgs.system}.default;
# admin key file (agenix/sops-nix path on the host)
passwordFile = config.age.secrets.kisassistant-password.path;
# storage (created by systemd StateDirectory, owned by the service user)
# SQLite: /var/lib/kisassistant/data
# CDN: /var/lib/kisassistant/cdn
dataDir = "/var/lib/kisassistant";
# LLM backends
ollamaHost = "http://10.66.66.4:11434";
extraEnv = {
MISTRAL_ENABLED = "true"; # non-secret toggles only — see the note below
};
};
```
The module automatically sets for the systemd unit:
| Env | Value |
|---|---|
| `NODE_ENV` | `production` |
| `DATA_DIR` | `/var/lib/kisassistant/data` |
| `CDN_DIR` | `/var/lib/kisassistant/cdn` |
| `DRIVER_DEBUG` | `false` |
| `PORT` / `HOST` | `127.0.0.1:3000` (configurable) |
The module also puts `tesseract` on the service `PATH`, so the OCR platform
tool (`POST /api/tools/ocr`, image attachments) works out of the box.
`OCR_LANG` / `OCR_MAX_CHARS` can be overridden via `extraEnv`.
> **API driver keys**: `extraEnv` values land in the unit's `Environment=`,
> which is world-readable in the systemd unit file. For driver API keys prefer
> `EnvironmentFile=` via a drop-in (or pass them from agenix with
> `systemd.services.kisassistant.serviceConfig.EnvironmentFile`), e.g.
> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a
> drop-in containing the key.
## 5. TLS reverse proxy (`enableNginx`)
The conventional switch is `enableNginx` — a tri-state:
| Value | Behaviour |
|---|---|
| `true` | nginx vhost + ACME configured (requires `domain`) |
| `false` | nginx never touched, even if `domain` is set |
| `null` (default) | legacy: nginx configured iff `domain` is set |
```nix
services.kisassistant = {
enableNginx = true;
domain = "chat.kisakay.com"; # vhost name + ACME cert name
# host/port default to 127.0.0.1:3000
};
# Required once per host for ACME (Let's Encrypt ToS):
security.acme.acceptTerms = true;
security.acme.defaults.email = "you@kisakay.com";
# Keep the backend loopback-only and let nginx own 80/443:
networking.firewall.allowedTCPPorts = [ 80 443 ];
```
The generated vhost: `forceSSL`, `enableACME`, `recommendedProxySettings`, and
`proxy_buffering off` on `/` so SSE token streaming (`/api/chat`) is never
buffered.
**Own nginx instead?** Set `enableNginx = false` (or omit `domain`) and write
your own vhost; proxy to `http://127.0.0.1:3000` and remember
`proxy_buffering off;` for `/` (or at least `/api/chat`).
## 6. First deploy & smoke test
```bash
sudo nixos-rebuild switch --flake .#myhost
```
Check the unit and the proxy:
```bash
systemctl status kisassistant
journalctl -u kisassistant -f # boot log (APP_PASSWORD errors are fatal)
curl -I https://chat.kisakay.com/ # 200 via nginx
curl -s https://chat.kisakay.com/api/models -H "Authorization: Bearer $TOKEN"
```
Log in through the web UI as `admin` with the `APP_PASSWORD` from your secret
file, then create user accounts from the **Accounts** panel (each gets a
one-time `ka_…` key). See `docs/OPERATIONS.md` for account management.
If port 3000 is used by something else on the host, change
`services.kisassistant.port` and the proxy target follows automatically.
## 7. Operations: logs, backups, updates
**Logs** — per-driver debug lines are off in production
(`DRIVER_DEBUG=false`); flip temporarily with:
```bash
sudo systemctl edit kisassistant # [Service] Environment=DRIVER_DEBUG=true
sudo systemctl restart kisassistant
```
**Backups** — everything is in `/var/lib/kisassistant`:
```bash
# SQLite is in WAL mode; prefer the backup API over raw copies:
sqlite3 /var/lib/kisassistant/data/kisassistant.db ".backup '/backup/kisassistant.db'"
systemctl stop kisassistant && tar -C /var/lib -czf /backup/kisassistant-$(date +%F).tgz kisassistant && systemctl start kisassistant
```
**Updates** — the usual flake flow:
```bash
nix flake update kisassistant # or update the rev you pin
sudo nixos-rebuild switch --flake .#myhost
sudo nix store gc # keep the previous generation around as needed
```
After dependency bumps the first build may fail with a `bunDepsHash` mismatch
— paste the reported hash into `flake.nix` (see section 2).
## 8. Module option reference
| Option | Type / default | Description |
|---|---|---|
| `enable` | bool | the switch |
| `package` | null **(required)** | flake package, e.g. `kisassistant.packages.${pkgs.system}.default` |
| `port` | port, `3000` | backend listen port |
| `host` | str, `127.0.0.1` | backend bind address (keep loopback behind nginx) |
| `dataDir` | str, `/var/lib/kisassistant` | state root (`data/` + `cdn/` live inside) |
| `user` | str, `kisassistant` | system user/group for the service |
| `passwordFile` | null \| path | file containing `APP_PASSWORD` (agenix/sops-nix) |
| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint |
| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` |
| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) |
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
-170
View File
@@ -1,170 +0,0 @@
# Operations
## Environment
Copy `.env.example` to `.env`. Full list:
| Variable | Default | Purpose |
|---|---|---|
| `NODE_ENV` | `development` | `production` under the NixOS module (and CI); gates the `DRIVER_DEBUG` default |
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
| `TESSERACT_BIN` | `tesseract` | path to the tesseract binary (NixOS module puts it on PATH; missing binary cleanly disables OCR with 501) |
| `DRIVER_DEBUG` | `true` dev / `false` prod | per-driver debug logging to stdout |
| `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | *(empty)* / `587` / `false` | key-recovery mailer — empty host disables it (`false` = STARTTLS, `true` = implicit TLS/465) |
| `SMTP_USER` / `SMTP_PASS` | *(empty)* | SMTP auth (omit both for open relays / local catchers) |
| `SMTP_FROM` | `KisAssistant <[email protected]>` | sender address |
| `APP_URL` | `http://localhost:3000` | public base URL used in reset links — **must** be the real origin in prod |
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) |
## Local development
```bash
bun install
bun run dev # backend watch mode (restarts on edit), :3000
bun run dev:frontend # Vite HMR + /api proxy, :5173 (use this for UI work)
bun run dev:all # both at once
bun run build:frontend # production build into public/
```
Smoke tests: start the backend on a **free non-3000 port** with a throwaway
`DATA_DIR` (port 3000 is the owner's dev server — do not touch it), then drive
the API with curl/python: login → models → conversations → chat → share →
unshare. See `AGENTS.md`.
## Managing accounts
1. Log in as `admin` with the `APP_PASSWORD` key.
2. Open **Accounts** → *New account* → username (+ display name).
3. Copy the one-time `ka_…` key and hand it to the owner (chat, QR code, …).
4. Users set their display name, avatar (upload a picture in the profile settings —
hosted on the built-in CDN), theme, accent color and feature flags in the settings.
5. Rotate a compromised key with the regenerate button (old sessions die);
delete removes the account with all its chats and shares.
## Admin Center (`/admin`) & self-registration
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
four tabs: **Accounts** (same manager as before, incl. the Ollama model
catalog), **Access** (access-request wishlist triage, see below),
**Features** (server-side switches), **Mail** (SMTP credentials viewer +
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
`POST /api/admin/mail/test`, admin Bearer only).
- `registration_enabled` (default off): when on, the login page shows a
working **Register** button — anyone can create an account (key shown once)
via `POST /api/auth/register`. When off, the button is greyed with the
tooltip *"Registration currently disabled on this platform"* and the
endpoint answers 403. Flipped live, no restart. Turning it on retires the
access-request wishlist automatically.
- `registration_request_enabled` (default on): shows **Request access** on the
login page (wishlist). Only switchable on while public registration is off —
the two are mutually exclusive (API answers 409 otherwise).
- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool.
- Flags live in the `settings` SQLite table, edited via
`GET/PATCH /api/admin/settings` (admin Bearer only).
## 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).
## Key recovery via email (optional)
Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise
the login page hides the "Forgot your access key?" link.
1. Admin sets a recovery email at account creation (or the user sets their own
in profile settings).
2. User clicks the link on the login page and enters their username. The API
always answers `{ok:true}` (no account enumeration) and, if the account has
an email, sends a one-time `/reset/<token>` link (60 min, single use).
3. Opening the link shows a confirm page; confirming **rotates the key
immediately** — the new `ka_…` key is displayed once, old sessions die.
(`admin` itself is excluded: its key lives in `.env`.)
## Local file CDN (avatars)
`PUT /cdn/<ns>/<key>` (Bearer) / `GET /cdn/<ns>/<key>.<ext>` (public), e.g.
`/cdn/avatar/<account-id>.png`. Namespaces live in `CDN_NAMESPACES`
(`src/cdn.ts`) — add one to extend (future usage).
- Allowed types **only** `jpg`/`png`/`webp`, verified by **magic bytes**, never
by extension (client filename is ignored; the stored extension comes from
detection). Everything else → `415`, unknown namespace/ext → `404`.
- `avatar` rules: 5 MB max (`413` above), **5 uploads max per 2 h per account**
(`429` + `Retry-After`), ownership enforced (own id, or admin).
- Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public,
max-age=3600`. No listing, no traversal (strict charsets + SPA fallback).
## Platform tools & attachments
Uploads always go through platform tools (`GET /api/tools` lists them with
availability). Today: **OCR** (`POST /api/tools/ocr`, tesseract).
- Images (jpg/png/webp, 5 MB max, magic-verified, 20 jobs/hour/account) are
transcribed **on the backend to TXT** — the model never receives image
bytes. Text files ride the `text` CDN namespace (UTF-8 validated, 500 KB
max, keys prefixed with the account id).
- The UI (paperclip menu in the composer) explains this and always shows a
**pre-transcription preview**: the user reviews/edits the text, then
attaches it. Attachments travel as labeled text blocks inside the message,
so they persist in history and work with every model.
## NixOS deployment (`chat.kisakay.com`)
Full step-by-step guide: **[NIXOS-HOSTING.md](./NIXOS-HOSTING.md)**.
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend,
frontend compiled offline from `frontend/bun.lock` via a fixed-output
`bun install`) and `nixosModules.default`.
```nix
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
package = kisassistant.packages.${pkgs.system}.default; # required
domain = "chat.kisakay.com"; # legacy switch: implies nginx + ACME
enableNginx = true; # explicit; requires domain
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434";
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
};
security.acme.acceptTerms = true; # required for the ACME/TLS vhost
```
The module sets `NODE_ENV=production`, `CDN_DIR=/var/lib/kisassistant/cdn`
and `DRIVER_DEBUG=false` for the systemd service. `enableNginx` is a
tri-state: `true`/`false` force the bundled reverse proxy on/off, `null`
(the default) keeps the legacy behaviour (nginx iff `domain` is set).
Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never
baked into the Nix store. The service stores SQLite under
`/var/lib/kisassistant/data`; nginx terminates TLS and proxies to
`127.0.0.1:3000`.
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"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
}
-80
View File
@@ -1,80 +0,0 @@
{
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/
'';
};
in pkgs.stdenv.mkDerivation {
pname = "kisassistant";
inherit version;
src = ./.;
nativeBuildInputs = [ pkgs.bun ];
buildPhase = ''
export HOME=$TMPDIR
# Backend has zero runtime deps; install only for @types/bun (typecheck).
bun install --frozen-lockfile 2>/dev/null || bun install --no-save || true
'';
installPhase = ''
mkdir -p $out/share/kisassistant
cp -r src package.json tsconfig.json .env.example $out/share/kisassistant/
cp -r ${frontend} $out/share/kisassistant/public
mkdir -p $out/bin
cat > $out/bin/kisassistant <<EOF
#!${pkgs.bash}/bin/bash
cd $out/share/kisassistant
exec ${pkgs.bun}/bin/bun run src/index.ts
EOF
chmod +x $out/bin/kisassistant
'';
};
};
}
-598
View File
@@ -1,598 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant-frontend",
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/inter": ["@fontsource-variable/[email protected]", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/[email protected]", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="],
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="],
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["[email protected]", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-jsx-runtime": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-url-attributes": ["[email protected]", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"inline-style-parser": ["[email protected]", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["[email protected]", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-decimal": ["[email protected]", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["[email protected]", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["[email protected]", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["[email protected]", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["[email protected]", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="],
"micromark-extension-gfm-tagfilter": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["[email protected]", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["[email protected]", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["[email protected]", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"node-releases": ["[email protected]", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="],
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"parse-entities": ["[email protected]", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-markdown": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"read-cache": ["[email protected]", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"remark-gfm": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"style-to-js": ["[email protected]", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["[email protected]", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["[email protected]", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unified": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
-44
View File
@@ -1,44 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>KisAssistant — private AI chat</title>
<meta
name="description"
content="KisAssistant — a private, self-hosted AI chat assistant. Talk to local Ollama models and cloud LLMs (Mistral, DeepSeek, Anthropic, OpenAI) with server-side chats, public shares and No-KYC accounts."
/>
<meta name="keywords" content="KisAssistant, private AI chat, self-hosted chatbot, Ollama web UI, Mistral, DeepSeek, Anthropic, OpenAI, local LLM" />
<meta name="author" content="kisakay" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#059669" />
<meta name="color-scheme" content="light dark" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="KisAssistant" />
<meta property="og:title" content="KisAssistant — private AI chat" />
<meta
property="og:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats, public shares, No-KYC accounts."
/>
<meta property="og:image" content="/logo.svg" />
<meta property="og:locale" content="en_US" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="KisAssistant — private AI chat" />
<meta
name="twitter:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats and public shares."
/>
<meta name="twitter:image" content="/logo.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/logo.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-3833
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
{
"name": "kisassistant-frontend",
"version": "0.2.0",
"description": "KisAssistant React frontend (Vite + Tailwind + lucide-react)",
"author": "kisakay",
"license": "MIT",
"repository": {
"type": "git",
"url": "http://git.kisakay.com/k/chat",
"directory": "frontend"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(60 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(120 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(180 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(240 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(300 50 50)"/>
<circle cx="50" cy="50" r="10" fill="#fbbf24"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 656 B

-4
View File
@@ -1,4 +0,0 @@
# 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: /
-364
View File
@@ -1,364 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts";
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts";
import { Login } from "./components/Login.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx";
import { AdminCenter } from "./components/AdminCenter.tsx";
import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx";
import { ReviewPage } from "./components/ReviewPage.tsx";
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
import { useT } from "./lib/i18n.ts";
function shareIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/);
return m ? m[1]! : null;
}
// Module-level: the path never changes without a full reload, so hook order stays stable.
const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null;
const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null;
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
const IS_ADMIN_PAGE = typeof window !== "undefined" && /^\/admin\/?$/.test(window.location.pathname);
function resetTokenFromPath(): string | null {
const m = window.location.pathname.match(/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/);
return m ? m[1]! : null;
}
function reviewIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/review\/([0-9a-f-]{36})\/?$/i);
return m ? m[1]! : null;
}
/** Minimum time the boot splash stays visible, so the bloom animation
* actually plays even when the session restores instantly. */
const MIN_SPLASH_MS = 2500;
function applyTheme(theme: string) {
const root = document.documentElement;
const dark =
theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
}
export function App() {
const [user, setUser] = useState<User | null>(null);
const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE);
const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | 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 { t } = useT();
// Public routes: no auth needed. IDs are constant for the page lifetime.
if (IS_ADMIN_PAGE) {
return <AdminCenter />;
}
if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />;
}
if (REVIEW_ID) {
return <ReviewPage ticketId={REVIEW_ID} />;
}
if (SHARE_ID) {
return <SharePage publicId={SHARE_ID} />;
}
const refreshConvs = useCallback(async (select?: string) => {
try {
const res = await api.convs();
setConvs(res.conversations);
if (select) setActiveId(select);
else if (res.conversations.length > 0) {
setActiveId((cur) => (cur && res.conversations.some((c) => c.id === cur) ? cur : res.conversations[0]!.id));
}
} catch {
// ignore (session errors reload the page via api layer)
}
}, []);
// Session restore (boot splash stays at least MIN_SPLASH_MS)
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()) {
done();
return;
}
api.verify()
.then(async (res) => {
setUser(res.user);
applyTheme(res.user.theme);
const [m, c, t] = await Promise.all([
api.models().catch(() => ({ models: [] })),
api.convs().catch(() => ({ conversations: [] })),
api.tools().catch(() => ({ tools: [] as { name: string; available: boolean }[] })),
]);
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
setConvs(c.conversations);
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
})
.catch(() => {})
.finally(done);
}, []);
// Load messages for active conversation
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
api.getConv(activeId).then((res) => setMessages(res.messages)).catch(() => setMessages([]));
}, [activeId]);
// Follow OS theme when account theme is auto
useEffect(() => {
if (user?.theme !== "auto") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const h = () => applyTheme("auto");
mq.addEventListener("change", h);
return () => mq.removeEventListener("change", h);
}, [user?.theme]);
function handleLogin(u: User) {
setUser(u);
applyTheme(u.theme);
api.models().then((m) => {
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
}).catch(() => {});
api.tools().then((t) => setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available))).catch(() => {});
refreshConvs();
}
function logout() {
api.logout().finally(() => {});
clearToken();
setUser(null);
setConvs([]);
setMessages([]);
setActiveId(null);
setAttachments([]);
setFilePreview(null);
}
async function newChat() {
try {
const res = await api.createConv({ model });
await refreshConvs(res.conversation.id);
} catch {
// fall back to local-only pending state
}
}
async function send(text: string) {
if (sending || !model) return;
let convId = activeId;
if (!convId) {
try {
const res = await api.createConv({ model });
convId = res.conversation.id;
await refreshConvs(convId);
} catch (err) {
return;
}
}
const userMsg: ChatMessage = { role: "user", content: text };
// Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map((a) =>
t("attach.tpl", { kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"), name: a.name }) +
`\n\`\`\`text\n${a.text}\n\`\`\``,
);
if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
const history = [...messages, userMsg];
setMessages(history);
setAttachments([]);
setSending(true);
setStreaming("");
let full = "";
try {
// "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;
await api.chatStream({ model, messages: outMessages, conversationId: convId! }, (t) => {
full += t;
setStreaming(full);
});
setMessages([...history, { role: "assistant", content: full }]);
setStreaming("");
await refreshConvs();
} catch {
setMessages([...history, { role: "assistant", content: t("attach.chatError") }]);
setStreaming("");
} finally {
setSending(false);
}
}
/** Platform-tools upload flow: image -> OCR, text -> CDN, both previewed before attach. */
async function handlePickFile(kind: "ocr" | "text", file: File) {
if (!user) return;
setAttachError(null);
if (kind === "ocr" && file.size > 5 * 1024 * 1024) {
setAttachError(t("attach.tooBigImg"));
return;
}
if (kind === "text" && file.size > 500 * 1024) {
setAttachError(t("attach.tooBigText"));
return;
}
setFilePreview({ name: file.name, kind, text: "", truncated: false });
setPreviewBusy(true);
try {
if (kind === "ocr") {
const r = await api.ocrImage(file);
setFilePreview({ name: file.name, kind, text: r.text, truncated: r.truncated });
} else {
const key = `${user.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
const put = await fetch(`/cdn/text/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": "text/plain" },
body: file,
});
const pdata = (await put.json().catch(() => ({}))) as { error?: string; url?: string };
if (!put.ok || !pdata.url) throw new Error(pdata.error || `Upload failed (${put.status})`);
const text = await (await fetch(pdata.url)).text();
setFilePreview({ name: file.name, kind, text, truncated: false });
}
} catch (e) {
setFilePreview(null);
setAttachError(e instanceof Error ? e.message : t("attach.uploadFailed"));
} finally {
setPreviewBusy(false);
}
}
function attachPreviewText(text: string) {
if (!filePreview || !text.trim()) return;
setAttachments((prev) => [...prev, { id: crypto.randomUUID(), name: filePreview.name, kind: filePreview.kind, text }]);
setFilePreview(null);
}
async function removeConv(c: Conversation) { try {
await api.deleteConv(c.id);
if (activeId === c.id) {
setActiveId(null);
setMessages([]);
}
await refreshConvs();
} catch {
// ignore
}
}
if (checking) {
return <LoadingScreen />;
}
if (!user) {
return <Login onLogin={handleLogin} />;
}
const active = convs.find((c) => c.id === activeId) ?? null;
return (
<div className="flex h-full">
<Sidebar
user={user}
convs={convs}
activeId={activeId}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(true)}
onNew={() => { newChat(); setMobileNav(false); }}
onSelect={(id) => { setActiveId(id); setMobileNav(false); }}
onRename={setEditConv}
onTopic={setEditConv}
onShare={setShareConv}
onDelete={setDeleteConv}
onOpenSettings={() => { setSettingsOpen(true); setMobileNav(false); }}
onOpenAdmin={() => { window.location.href = "/admin"; }}
onLogout={logout}
mobileOpen={mobileNav}
onCloseMobile={() => setMobileNav(false)}
/>
<Chat
user={user}
conv={active}
messages={messages}
streaming={streaming}
sending={sending}
models={models}
model={model}
onModelChange={setModel}
onSend={send}
onShare={() => active && setShareConv(active)}
sidebarCollapsed={sidebarCollapsed}
onExpandSidebar={() => setSidebarCollapsed(false)}
onOpenNav={() => setMobileNav(true)}
attachments={attachments}
attachError={attachError}
ocrAvailable={ocrAvailable}
onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((a) => a.id !== id))}
onPickFile={handlePickFile}
/>
<FilePreviewModal
preview={filePreview}
busy={previewBusy}
onClose={() => { if (!previewBusy) setFilePreview(null); }}
onAttach={attachPreviewText}
/>
<ConvEditDialog
conv={editConv}
onClose={() => setEditConv(null)}
onSaved={(c) => {
setConvs((prev) => prev.map((x) => (x.id === c.id ? c : x)));
}}
/>
<ShareModal conv={shareConv} onClose={() => setShareConv(null)} />
<ConfirmDialog
open={deleteConv !== null}
onClose={() => setDeleteConv(null)}
title={t("app.deleteConvTitle")}
message={t("app.deleteConvMsg", { title: deleteConv?.title ?? "" })}
onConfirm={() => deleteConv && removeConv(deleteConv)}
/>
<SettingsModal
user={settingsOpen ? user : null}
onClose={() => setSettingsOpen(false)}
onSaved={(u) => {
setUser(u);
applyTheme(u.theme);
}}
// Key rotated or account deleted: sessions are dead server-side.
onKeyRotated={() => { setSettingsOpen(false); logout(); }}
/>
</div>
);
}
-242
View File
@@ -1,242 +0,0 @@
import { useEffect, useState } from "react";
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<AccessStatus, { label: string; cls: string }> = {
pending: { label: "Pending", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
reviewing: { label: "Reviewing", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
};
type Filter = "all" | AccessStatus;
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
/** Admin triage for the access-request wishlist: accept / refuse / review. */
export function AccessRequestsPanel() {
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
const [filter, setFilter] = useState<Filter>("pending");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
async function refresh(select?: string) {
try {
const res = await api.adminAccessList();
setRequests(res.requests);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : "Load failed");
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
}, []);
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",
)}
>
{f} · {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="Refresh"
aria-label="Refresh requests"
>
<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} /> 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 ? "Nothing with this status." : "No access requests. The wishlist is 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} message{r.message_count === 1 ? "" : "s"}</span>
</span>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
{STATUS_STYLE[r.status].label}
</span>
</button>
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
</li>
))}
</ul>
</div>
);
}
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [reason, setReason] = useState(request.reason);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [freshKey, setFreshKey] = useState<string | null>(null);
useEffect(() => {
setReason(request.reason);
setFreshKey(null);
api.adminAccessGet(request.id).then((res) => setMessages(res.messages)).catch(() => {});
}, [request.id, request.reason]);
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);
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Update failed");
} finally {
setBusy(false);
}
}
async function reply(e: React.FormEvent) {
e.preventDefault();
const body = draft.trim();
if (!body || busy) return;
setBusy(true);
setError("");
try {
const res = await api.adminAccessReply(request.id, body);
setMessages((prev) => [...prev, res.message]);
setDraft("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Send failed");
} 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} /> Account created — key shown once, copy it now:</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" ? "You (admin)" : `@${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="Reply (emailed to the requester)…"
aria-label="Reply to requester"
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="Send reply"
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="Reason (emailed with the decision)">
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Optional reason…" 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} /> Reviewing
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => setStatus("accepted")}>
<CheckCircle2 size={14} /> Accept
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("refused")} className="!text-red-600 dark:!text-red-400">
<XCircle size={14} /> Refuse
</Button>
</div>
</div>
) : (
<p className="flex items-center gap-2 text-xs opacity-60">
<Clock size={13} /> Ticket closed ({STATUS_STYLE[request.status].label.toLowerCase()}).
{request.reason && <> Reason: <em>{request.reason}</em></>}
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
-333
View File
@@ -1,333 +0,0 @@
import { useEffect, useState } from "react";
import { ArrowLeft, Eye, EyeOff, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "features" | "mail";
function applyTheme(theme: string) {
const root = document.documentElement;
const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
}
export function AdminCenter() {
const { t } = useT();
const [allowed, setAllowed] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>("accounts");
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const 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: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail },
];
useEffect(() => {
api.verify()
.then((r) => {
if (!r.user.isAdmin) {
setAllowed(false);
return;
}
setAllowed(true);
applyTheme(r.user.theme);
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
})
.catch(() => setAllowed(false));
}, []);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
setSaving(true);
setError("");
try {
const res = await api.adminPatchSettings(patch);
setSettings(res.settings);
} catch (e) {
setError(e instanceof Error ? e.message : t("common.saveFailed"));
} finally {
setSaving(false);
}
}
if (allowed === null) {
return <LoadingScreen />;
}
if (!allowed) {
return (
<div className="grid min-h-full place-items-center p-6">
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200 bg-white p-8 text-center shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
<ShieldAlert size={28} className="mx-auto mb-3 text-red-500" />
<h1 className="text-lg font-bold">{t("center.locked")}</h1>
<p className="mt-1 text-sm opacity-60">{t("center.lockedSub")}</p>
<a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">{t("common.backToLogin")}</a>
</div>
</div>
);
}
return (
<div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-4 py-3">
<a href="/" aria-label={t("center.back")} className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<ArrowLeft size={18} />
</a>
<FlowerMark size={32} dynamic={false} />
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" />
{t("center.title")}
</h1>
</div>
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
<div className="flex gap-1 overflow-x-auto rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
tab === t.id
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
: "opacity-60 hover:opacity-100",
)}
>
<t.icon size={15} />
{t.label}
</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 === "features" && (
<section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.regTitle")}</p>
<p className="text-sm opacity-60">{t("center.regDesc")}</p>
</div>
<Switch label={t("center.regTitle")} checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} />
</div>
<div className={cn(
"flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
settings?.registrationEnabled && "opacity-60",
)}>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.accTitle")}</p>
<p className="text-sm opacity-60">
{settings?.registrationEnabled
? t("center.accDescOn")
: t("center.accDescOff")}
</p>
</div>
<Switch
label={t("center.accTitle")}
checked={settings?.accessRequestEnabled ?? false}
disabled={settings?.registrationEnabled ?? false}
onChange={(v) => toggle({ accessRequestEnabled: v })}
/>
</div>
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.ocrTitle")}</p>
<p className="text-sm opacity-60">{t("center.ocrDesc")}</p>
</div>
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
</div>
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
</section>
)}
{tab === "mail" && <MailPanel />}
</main>
</div>
);
}
type SmtpInfo = {
enabled: boolean;
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
hasPass: boolean;
from: string;
appUrl: string;
};
function MailPanel() {
const { t } = useT();
const [smtp, setSmtp] = useState<SmtpInfo | null>(null);
const [error, setError] = useState("");
const [showPass, setShowPass] = useState(false);
const [to, setTo] = useState("");
const [verifying, setVerifying] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
}, []);
async function verify() {
setVerifying(true);
setResult(null);
try {
await api.adminMailVerify();
setResult({ ok: true, text: t("center.connOk") });
} catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.verifyFailed") });
} finally {
setVerifying(false);
}
}
async function sendTest() {
setSending(true);
setResult(null);
try {
const r = await api.adminMailTest(to.trim());
setResult({ ok: true, text: t("center.testSent", { to: to.trim(), tail: r.messageId ? ` (${r.messageId})` : "" }) });
} catch (e) {
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.mailSendFailed") });
} finally {
setSending(false);
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 py-2">
<span className="w-24 shrink-0 text-xs font-medium uppercase tracking-wider opacity-50">{label}</span>
<span className="min-w-0 flex-1 truncate text-sm">{children}</span>
</div>
);
}
return (
<div className="space-y-4">
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Mail size={17} />
</span>
<div>
<p className="text-sm font-medium">{t("center.mailTitle")}</p>
<p className="text-sm opacity-60">
{smtp == null ? t("center.mailChecking") : smtp.enabled ? t("center.mailFrom", { from: smtp.from }) : t("center.mailOff")}
</p>
</div>
<span className={cn(
"ml-auto rounded-full px-3 py-1 text-xs font-medium",
smtp?.enabled ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-stone-200/70 dark:bg-zinc-800",
)}>
{smtp?.enabled ? t("center.on") : t("center.off")}
</span>
</div>
{error && <p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{smtp && (
<div className="mt-3 divide-y divide-stone-200/70 rounded-2xl border border-stone-200/70 px-4 dark:divide-zinc-800 dark:border-zinc-800">
<Row label={t("center.smtpHost")}>{smtp.host || <span className="opacity-50">{t("center.notSet")}</span>}</Row>
<Row label={t("center.smtpPort")}>{smtp.port} · {smtp.secure ? t("center.tlsImplicit") : t("center.tlsStarttls")}</Row>
<Row label={t("center.smtpUser")}>{smtp.user || <span className="opacity-50">{t("center.noAuth")}</span>}</Row>
<Row label={t("center.smtpPass")}>
<span className="flex items-center gap-2">
{!smtp.hasPass ? (
<span className="opacity-50">{t("center.notSet")}</span>
) : showPass ? (
<code className="truncate font-mono">{smtp.pass}</code>
) : (
<code className="select-none blur-sm" aria-label={t("center.hiddenPass")}>••••••••••</code>
)}
{smtp.hasPass && (
<>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
aria-label={showPass ? t("center.hidePass") : t("center.showPass")}
className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"
>
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
{showPass && <CopyButton text={smtp.pass} />}
</>
)}
</span>
</Row>
<Row label={t("center.smtpFrom")}><span className="truncate">{smtp.from}</span></Row>
<Row label={t("center.smtpAppUrl")}><span className="truncate">{smtp.appUrl}</span></Row>
</div>
)}
<p className="mt-3 text-sm opacity-60">
{t("center.credsNote")}
</p>
</section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">{t("center.mailTestTitle")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("center.mailTestDesc")}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={verify} disabled={verifying || !smtp?.enabled}>
{verifying ? <Spinner size={14} /> : null}
{verifying ? t("center.verifying") : t("center.verifyConn")}
</Button>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<Input
type="email"
placeholder="[email protected]"
aria-label={t("center.testToAria")}
value={to}
onChange={(e) => setTo(e.target.value)}
className="flex-1"
/>
<Button size="sm" onClick={sendTest} disabled={sending || !smtp?.enabled || !/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(to.trim())}>
{sending ? <Spinner size={14} /> : <Send size={14} />}
{sending ? t("center.sendingMail") : t("center.sendTest")}
</Button>
</div>
{result && (
<p
role={result.ok ? "status" : "alert"}
className={cn(
"mt-3 rounded-2xl px-4 py-2.5 text-sm",
result.ok
? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
{result.text}
</p>
)}
</section>
</div>
);
}
-480
View File
@@ -1,480 +0,0 @@
import { useEffect, useState, type FC, type ReactNode } from "react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { DriverModel, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx";
/** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */
const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [
{ name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] },
{ name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] },
{ name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
{ name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] },
{ name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] },
{ name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] },
{ name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
{ name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] },
{ name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] },
{ name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] },
];
/* ---------- Ollama model catalog (admin) ---------- */
export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useT();
const [installed, setInstalled] = useState<DriverModel[]>([]);
const [pullName, setPullName] = useState("");
const [pullStatus, setPullStatus] = useState("");
const [pullPct, setPullPct] = useState<number | null>(null);
const [pulling, setPulling] = useState(false);
const [error, setError] = useState("");
const [deleteModel, setDeleteModel] = useState<string | null>(null);
const [sizeOverrides, setSizeOverrides] = useState<Record<string, string>>({});
async function refreshInstalled() {
try {
// Admin view: force a fresh upstream fetch, bypassing the 60s user cache.
const res = await api.models({ refresh: true });
setInstalled(res.models.filter((m) => m.driver === "ollama"));
} catch { /* ignore */ }
}
useEffect(() => {
if (open) {
setError("");
setPullStatus("");
setPullPct(null);
setPulling(false);
refreshInstalled();
}
}, [open]);
async function pull(fullName: string) {
if (pulling || !fullName.trim()) return;
setPulling(true);
setError("");
setPullStatus(t("ollama.contacting"));
setPullPct(null);
try {
await api.ollamaPull(fullName.trim(), (p) => {
if (p.error) { setError(p.error); return; }
setPullStatus(p.status || t("ollama.pullingSt"));
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
else setPullPct(null);
});
setPullStatus(t("ollama.doneSt"));
setPullPct(100);
await refreshInstalled();
} catch (err) {
setError(err instanceof Error ? err.message : t("ollama.pullFailed"));
} finally {
setPulling(false);
}
}
async function removeModel(name: string) {
try {
await api.ollamaDelete(name);
await refreshInstalled();
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
}
}
return (
<>
<Modal open={open} onClose={pulling ? () => {} : onClose} title={t("ollama.models")} icon={Bot}>
<p className="mb-4 text-sm opacity-70">
{t("ollama.desc")}
</p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
{pulling && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Download size={15} className="animate-bounce" />
{t("ollama.pulling", { name: pullName })}
</p>
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
{pullPct !== null && (
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800">
<div className="h-full rounded-full bg-accent-600 transition-all dark:bg-accent-500" style={{ width: `${pullPct}%` }} />
</div>
)}
</div>
)}
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-52 flex-1">
<Field label={t("ollama.customTag")} hint={t("ollama.customHint")}>
<Input
value={pullName}
onChange={(e) => setPullName(e.target.value)}
placeholder="llama3.2:3b"
disabled={pulling}
onKeyDown={(e) => e.key === "Enter" && pull(pullName)}
/>
</Field>
</div>
<Button size="sm" onClick={() => pull(pullName)} disabled={pulling || !pullName.trim()}>
{pulling ? <Spinner size={15} /> : <Download size={15} />} {t("ollama.pull")}
</Button>
</div>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> {t("ollama.installed", { n: installed.length })}</p>
<ul className="mb-5 space-y-1.5">
{installed.length === 0 && <li className="text-sm opacity-50">{t("ollama.noneYet")}</li>}
{installed.map((m) => (
<li key={m.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1 truncate text-sm font-medium">{m.label || m.id}</span>
<button
title={t("ollama.delTip", { name: m.label || m.id })}
onClick={() => setDeleteModel(m.label || m.id)}
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
>
<Trash2 size={15} />
</button>
</li>
))}
</ul>
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> {t("ollama.catalog")}</p>
<ul className="space-y-1.5">
{OLLAMA_CATALOG.map((m) => {
const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : "");
const tag = size ? `${m.name}:${size}` : m.name;
return (
<li key={m.name} className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{m.name}</span>
<span className="block text-xs opacity-50">{t(m.catKey)}</span>
</span>
{m.sizes.length > 0 && (
<span className="w-24 shrink-0">
<Picker
ariaLabel={t("ollama.sizeAria", { name: m.name })}
value={size}
onChange={(v) => setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))}
align="right"
options={m.sizes.map((s) => ({ value: s, label: s }))}
/>
</span>
)}
<Button size="sm" variant="secondary" onClick={() => { setPullName(tag); pull(tag); }} disabled={pulling}>
<Download size={14} /> {t("ollama.pull")}
</Button>
</li>
);
})}
</ul>
</Modal>
<ConfirmDialog
open={deleteModel !== null}
onClose={() => setDeleteModel(null)}
title={t("ollama.delTitle", { name: deleteModel ?? "" })}
message={t("ollama.delMsg")}
onConfirm={() => deleteModel && removeModel(deleteModel)}
/>
</>
);
}
export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) {
const { t } = useT();
const [users, setUsers] = useState<User[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
const [modelsOpen, setModelsOpen] = useState(false);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [sort, setSort] = useState("newest");
const [filter, setFilter] = useState("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const PER_PAGE = 8;
// create form
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
async function refresh() {
setBusy(true);
setError("");
try {
const res = await api.adminList({ q: debouncedQ || undefined, sort, filter, page, per: PER_PAGE });
setUsers(res.users);
setTotal(res.total);
setPages(res.pages);
if (res.page !== page) setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
setBusy(false);
}
}
useEffect(() => {
if (open || bare) {
refresh();
setShowCreate(false);
setFreshKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, bare ]);
// Debounced search (resets to first page); sort/filter reload too.
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQ(q);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [q ]);
useEffect(() => {
if (open || bare) refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQ, sort, filter, page ]);
async function create(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined, email: email.trim() || undefined });
setFreshKey({ username: res.user.username, key: res.key });
setUsername("");
setDisplayName("");
setEmail("");
setShowCreate(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.createFailed"));
}
}
async function regenerate(u: User) {
setError("");
try {
const res = await api.adminRegenerate(u.id);
setFreshKey({ username: u.username, key: res.key });
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.regenFailed"));
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
}
}
// Bare mode embeds the same manager in the /admin Center page instead
// of a modal — no logic duplicated.
const Shell: FC<{ children: ReactNode }> = bare
? ({ children }) => <div className="min-w-0 flex-1 space-y-1">{children}</div>
: ({ children }) => <Modal open={open} onClose={onClose} title={t("admin.title")} icon={Users} wide>{children}</Modal>;
return (
<>
<Shell>
<p className="mb-4 text-sm opacity-70">
{t("admin.intro")}
</p>
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{freshKey && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> {t("admin.keyOnce", { user: freshKey.username })}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey.key}</code>
<CopyButton text={freshKey.key} />
</div>
</div>
)}
{!showCreate ? (
<div className="mb-5 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
<UserPlus size={15} /> {t("admin.newAccount")}
</Button>
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
<Bot size={15} /> {t("ollama.models")}
</Button>
</div>
) : (
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<div className="min-w-40 flex-1">
<Field label={t("common.username")}><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
</div>
<Button size="sm" type="submit">{t("common.create")}</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>{t("common.cancel")}</Button>
</form>
)}
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-40 flex-1">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("admin.searchPh")} aria-label={t("admin.searchAria")} className="pl-10" />
</div>
<Picker
ariaLabel={t("admin.sortAria")}
icon={ArrowUpDown}
value={sort}
onChange={(v) => { setSort(v); setPage(1); }}
align="right"
options={[
{ value: "newest", label: t("admin.sortNewest") },
{ value: "oldest", label: t("admin.sortOldest") },
{ value: "az", label: t("admin.sortAz") },
{ value: "za", label: t("admin.sortZa") },
]}
/>
<Picker
ariaLabel={t("admin.filterAria")}
icon={Filter}
value={filter}
onChange={(v) => { setFilter(v); setPage(1); }}
align="right"
options={[
{ value: "all", label: t("admin.filterAll") },
{ value: "with-email", label: t("admin.filterWith") },
{ value: "no-email", label: t("admin.filterNo") },
]}
/>
</div>
{busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>
) : (
<ul className="space-y-2">
{users.map((u) => (
<li key={u.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2.5 dark:border-zinc-800">
<Avatar name={u.displayName} url={u.avatarUrl} size={36} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
<p className="truncate text-xs opacity-50">{u.email || t("admin.noEmail")} · {new Date(u.createdAt).toLocaleDateString()}</p>
</div>
{!u.isAdmin && (
<div className="flex shrink-0 gap-1">
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div>
)}
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center justify-between gap-2 text-sm">
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
{pages > 1 && (
<div className="flex gap-2.5">
<IconButton
title={t("admin.prevPage")}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={16} />
</IconButton>
<IconButton
title={t("admin.nextPage")}
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</IconButton>
</div>
)}
</div>
</Shell>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t("admin.deleteTitle", { user: deleteTarget?.username ?? "" })}
message={t("admin.deleteMsg")}
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
<OllamaModelsModal open={modelsOpen} onClose={() => setModelsOpen(false)} />
</>
);
}
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const { t } = useT();
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState("");
const [theme, setTheme] = useState("auto");
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setEmail(user.email);
setTheme(user.theme);
setError("");
}
}, [user]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!user) return;
try {
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
}
}
return (
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
<Field label={t("common.theme")}>
<Picker
ariaLabel={t("common.theme")}
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: t("common.themeAuto") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
]}
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" type="submit">{t("common.save")}</Button>
</div>
</form>
</Modal>
);
}
-287
View File
@@ -1,287 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts";
import { useT } from "../lib/i18n.ts";
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
const { t } = useT();
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
{isUser
? <Avatar name={authorName} url={authorAvatar} size={32} />
: <AssistantAvatar size={32} />}
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
{isUser ? t("chat.you") : "KisAssistant"}
</div>
{isUser ? (
<div className="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">
{msg.content}
</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={msg.content} />
</div>
)}
</div>
</div>
);
}
export function Chat({
user,
conv,
messages,
streaming,
sending,
models,
model,
onModelChange,
onSend,
onShare,
sidebarCollapsed,
onExpandSidebar,
onOpenNav,
attachments,
attachError,
ocrAvailable,
onRemoveAttachment,
onPickFile,
}: {
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;
sidebarCollapsed: boolean;
onExpandSidebar: () => void;
onOpenNav: () => void;
attachments: Attachment[];
attachError: string | null;
ocrAvailable: boolean;
onRemoveAttachment: (id: string) => void;
onPickFile: (kind: "ocr" | "text", file: File) => void;
}) {
const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false);
const { t } = useT();
const features = useFeatures();
const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const imgRef = useRef<HTMLInputElement>(null);
const txtRef = useRef<HTMLInputElement>(null);
const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
for (const m of models) {
const list = byDriver.get(m.driver) ?? [];
list.push({ value: m.id, label: m.label, hint: m.id });
byDriver.set(m.driver, list);
}
return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options }));
})();
useEffect(() => {
const el = boxRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, streaming]);
function autoGrow() {
const ta = taRef.current;
if (ta) {
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 200) + "px";
}
}
function submit(e?: React.FormEvent) {
e?.preventDefault();
const text = draft.trim();
if (!text || sending) return;
setDraft("");
requestAnimationFrame(autoGrow);
onSend(text);
}
return (
<div className="flex h-full min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
<IconButton title={t("chat.openNav")} onClick={onOpenNav} className="md:hidden">
<Menu size={18} />
</IconButton>
{sidebarCollapsed && (
<span className="hidden md:inline">
<IconButton title={t("chat.expand")} onClick={onExpandSidebar}>
<PanelLeftOpen size={18} />
</IconButton>
</span>
)}
<span className="hidden items-center gap-2 font-semibold sm:flex">
<FlowerMark size={24} dynamic={false} />
{conv?.title || t("sidebar.newChat")}
</span>
{conv?.topic && (
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
)}
<div className="ml-auto flex items-center gap-2">
<Picker
ariaLabel={t("chat.modelAria")}
icon={Cpu}
value={model}
onChange={onModelChange}
groups={modelGroups}
placeholder={models.length === 0 ? t("chat.noModels") : t("chat.selectModel")}
disabled={models.length === 0}
/>
{conv && (
<IconButton title={t("sidebar.share")} onClick={onShare}>
<Share2 size={17} />
</IconButton>
)}
</div>
</header>
<div ref={boxRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-6 sm:px-8">
<div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && (
<div className="py-16 text-center">
<FlowerMark size={56} dynamic={false} className="mx-auto mb-4" />
<h2 className="text-2xl font-bold tracking-tight">{t("chat.greet", { name: user.displayName.split(" ")[0]! })}</h2>
<p className="mt-1 text-sm opacity-60">{t("chat.greetSub")}</p>
</div>
)}
{messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} authorAvatar={user.avatarUrl} authorName={user.displayName} />
))}
{streaming !== "" && (
<div className="flex gap-3">
<AssistantAvatar size={32} />
<div className="min-w-0 max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="typing-cursor"><Markdown text={streaming} /></div>
</div>
</div>
</div>
)}
{sending && streaming === "" && (
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
)}
</div>
</div>
<div className="px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-8">
<div className="mx-auto w-full max-w-3xl">
{attachError && (
<p className="mb-2 rounded-2xl bg-red-500/10 px-4 py-2 text-sm text-red-600 dark:text-red-400" role="alert">{attachError}</p>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10">
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
<span className="max-w-40 truncate">{a.name}</span>
<button onClick={() => onRemoveAttachment(a.id)} aria-label={t("chat.removeAttach", { name: a.name })} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
<X size={13} />
</button>
</span>
))}
</div>
)}
</div>
<div className="relative mx-auto w-full max-w-3xl">
{attachOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAttachOpen(false)} />
<div className="absolute bottom-full left-0 z-50 mb-2 w-80 max-w-[calc(100vw-2rem)] rounded-3xl border border-stone-200 bg-white p-2 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900">
<button
onClick={() => { setAttachOpen(false); imgRef.current?.click(); }}
disabled={!ocrAvailable}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<ScanText size={16} />
</span>
<span>
<span className="block text-sm font-medium">{t("chat.ocrOpt")} {ocrAvailable ? "" : t("chat.unavailable")}</span>
<span className="block text-xs opacity-60">{t("chat.ocrHint")}</span>
</span>
</button>
<button
onClick={() => { setAttachOpen(false); txtRef.current?.click(); }}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<FileText size={16} />
</span>
<span>
<span className="block text-sm font-medium">{t("chat.textOpt")}</span>
<span className="block text-xs opacity-60">{t("chat.textHint")}</span>
</span>
</button>
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
{t("chat.attachNote")}
</p>
</div>
</>
)}
</div>
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
{features.attachments && (
<button
type="button"
onClick={() => setAttachOpen((o) => !o)}
aria-label={t("chat.attachTip")}
title={t("chat.attachTip")}
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
>
<Paperclip size={17} className="opacity-70" />
</button>
)}
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
<textarea
ref={taRef}
rows={1}
value={draft}
onChange={(e) => { setDraft(e.target.value); autoGrow(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={t("chat.placeholder")}
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
style={{ maxHeight: 200 }}
/>
<button
type="submit"
disabled={(!draft.trim() && attachments.length === 0) || sending}
aria-label={t("chat.send")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
<SendHorizontal size={17} />
</button>
</form>
<p className="py-2 text-center text-xs opacity-50">
<a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
<BookOpen size={12} />
{t("chat.wiki")}
</a>
</p>
</div>
</div>
);
}
-219
View File
@@ -1,219 +0,0 @@
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 { Button, FlowerMark, Input, Spinner } from "./ui.tsx";
import { RecoverDialog, RegisterDialog } from "./dialogs.tsx";
import { AccessRequestModal } from "./ReviewPage.tsx";
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const { t } = useT();
const [username, setUsername] = useState("admin");
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [recoverOpen, setRecoverOpen] = useState(false);
const [recoveryOn, setRecoveryOn] = useState(false);
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
const [registerOpen, setRegisterOpen] = useState(false);
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(() => {});
}, []);
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-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center">
<FlowerMark size={68} dynamic={false} className="mb-4 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("login.welcome")}</h1>
<p className="mt-1 text-sm opacity-60">{t("login.subtitle")}</p>
</div>
{totpToken ? (
<form onSubmit={submitTotp} className="mt-6 space-y-3">
<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-6 space-y-3">
<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-4 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-3 w-full"
disabled={!registrationOn}
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
onClick={() => setRegisterOpen(true)}
>
<UserPlus size={16} />
{t("login.register")}
</Button>
<RegisterDialog
open={registerOpen}
onClose={() => setRegisterOpen(false)}
onDone={(u, k) => { setUsername(u); setKey(k); }}
/>
{/* 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-3 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-3 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} />
<ul className="mt-6 space-y-2 border-t border-stone-200/70 pt-5 dark:border-zinc-800">
{PERKS.map((p) => (
<li key={p.text} className="flex items-center gap-2.5 text-[13px] opacity-70">
<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">
<p.icon size={14} />
</span>
{p.text}
</li>
))}
</ul>
</div>
</div>
</div>
);
}
-45
View File
@@ -1,45 +0,0 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
return (
<div className="relative">
{lang && (
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
)}
<div className="absolute right-2.5 top-2">
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
</pre>
</div>
);
}
export function Markdown({ text }: { text: string }) {
return (
<div className="md space-y-3 text-[15px] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre({ children }) {
// react-markdown nests <code> inside <pre>; render our block instead.
const child = Array.isArray(children) ? children[0] : children;
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
const className = props?.className || "";
const lang = className.replace("language-", "");
const code = String(props?.children ?? "").replace(/\n$/, "");
return <CodeBlock code={code} lang={lang} />;
},
code({ children }) {
return <code>{children}</code>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
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";
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 [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 : "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} /> Checking link…</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" /> Link invalid</p>
<p className="text-sm opacity-70">This reset link is unknown, already used, or expired. Request a new one from the login page.</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
</div>
)}
{state.kind === "ready" && (
<div className="space-y-4">
<h1 className="text-xl font-bold">Reset key for @{state.username}?</h1>
<p className="text-sm opacity-70">This issues a fresh access key immediately. Your old key stops working.</p>
<Button variant="gradient" size="lg" className="w-full" onClick={consume}>
<KeyRound size={16} /> Issue new key
</Button>
</div>
)}
{state.kind === "working" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> 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" /> New key ready</h1>
<p className="text-sm opacity-70">Copy it now — it won't be shown again. Then log in with it.</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 = "/")}>Go to login</Button>
</div>
)}
{state.kind === "error" && (
<div className="space-y-3">
<p className="font-semibold text-red-600 dark:text-red-400">Reset failed</p>
<p className="text-sm opacity-70">{state.message}</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
</div>
)}
</div>
</div>
);
}
-246
View File
@@ -1,246 +0,0 @@
import { useEffect, useState } from "react";
import { CheckCircle2, Clock, MailWarning, MessageSquareText, Search, SendHorizontal, XCircle } from "lucide-react";
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
import { Button, CopyButton, Field, FlowerMark, Input, Modal, Spinner } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<AccessRequest["status"], { label: string; cls: string; icon: typeof Clock }> = {
pending: { label: "Pending review", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
reviewing: { label: "Under review", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 },
refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle },
};
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
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} />
{s.label}
</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 [sending, setSending] = useState(false);
const [error, setError] = useState("");
async function load() {
try {
const res = await api.accessTicket(ticketId);
setRequest(res.request);
setMessages(res.messages);
} catch {
setInvalid(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]);
async function send(e: React.FormEvent) {
e.preventDefault();
const body = draft.trim();
if (!body || sending) return;
setSending(true);
setError("");
try {
const res = await api.accessReply(ticketId, body);
setMessages((prev) => [...prev, res.message]);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : "Send failed");
} 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">Access request</h1>
{loading && <p className="mt-4 flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading ticket…</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" /> Ticket not found</p>
<p className="text-sm opacity-70">This link is unknown. Check the URL from your email, or file a new request from the login page.</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</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">Admin note: </span>{request.reason}
</p>
)}
{request.status === "accepted" && (
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
Your account was created — check your email for the access key, then log in.
</p>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
{messages.map((m) => (
<div key={m.id} className={cn("flex", m.author === "admin" ? "justify-start" : "justify-end")}>
<div className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2 text-sm",
m.author === "admin"
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
: "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950",
)}>
<p className="mb-0.5 text-[11px] font-medium opacity-70">{m.author === "admin" ? "Admin" : "You"}</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</div>
</div>
))}
</div>
{closed ? (
<p className="text-center text-xs opacity-60">
This ticket is closed. Replies by email are not read — if needed, file a new request.
</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="Argue your case…"
aria-label="Reply to your ticket"
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="Send reply"
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">
Email notifications are one-way — always reply here, not by email.
</p>
</form>
)}
</div>
)}
</div>
</div>
);
}
/** Wishlist form: reserve a username + email with a motivation message. */
export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [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 : "Request failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Request access" 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} /> Request received
</p>
<p className="text-sm opacity-70">
@{done.username} is reserved for you while an admin reviews it.
A confirmation email with your personal ticket link is on its way.
</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="Copy link" />
</div>
<Button size="sm" className="w-full" onClick={() => (window.location.href = done.reviewUrl.replace(window.location.origin, ""))}>
Open my ticket
</Button>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm opacity-70">
Reserve a username and tell us why you want in. An admin reviews every request —
you follow the discussion on your personal ticket page and by email.
</p>
<Field label="Username">
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required minLength={2} maxLength={32} />
</Field>
<Field label="Email" hint="Required — status updates and ticket replies land here.">
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" required />
</Field>
<Field label="Why should you get access?" hint="10+ characters. This starts your ticket thread.">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="I want to…"
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}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Send request"}</Button>
</div>
</form>
)}
</Modal>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { useEffect, useState } from "react";
import { 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";
export function SharePage({ publicId }: { publicId: string }) {
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 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()}</>}
</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">
Open KisAssistant
</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">This shared chat is gone</h1>
<p className="mt-2 text-sm opacity-70">
The link is invalid, expired, or the author unshared it.
{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">
Open KisAssistant
</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"
>
Go 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">Loading shared chat…</p>}
{chat?.messages.map((m, i) => (
<div key={i} className="flex gap-3">
{m.role === "user"
? <Avatar name={chat.authorName} url={chat.authorAvatarUrl || undefined} size={32} />
: <AssistantAvatar size={32} />}
<div className="min-w-0 max-w-[90%] flex-1">
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
{m.role === "user" ? chat.authorName : "KisAssistant"}
</div>
{m.role === "user" ? (
<div className="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">{m.content}</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<Markdown text={m.content} />
</div>
)}
</div>
</div>
))}
</main>
</div>
);
}
-296
View File
@@ -1,296 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
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,
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;
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: 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="m-0.5 flex w-[calc(100%-4px)] 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="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>
</>
);
}
-907
View File
@@ -1,907 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react";
import { api } from "../lib/api.ts";
import { cn } from "../lib/cn.ts";
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
import type { Conversation, FilePreview, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { Avatar, bumpCdnVersion, Button, ConfirmDialog, CopyButton, Field, Input, LangPicker, Modal, Picker, Spinner, Toggle } from "./ui.tsx";
/* ---------- Rename + topic ---------- */
export function ConvEditDialog({
conv,
onClose,
onSaved,
}: {
conv: Conversation | null;
onClose: () => void;
onSaved: (c: Conversation) => void;
}) {
const { t } = useT();
const [title, setTitle] = useState("");
const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (conv) {
setTitle(conv.title);
setTopic(conv.topic);
setError("");
}
}, [conv]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!conv || busy) return;
setBusy(true);
setError("");
try {
const res = await api.patchConv(conv.id, { title: title.trim(), topic: topic.trim() });
onSaved(res.conversation);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal open={conv !== null} onClose={onClose} title={t("dlg.editConv")} icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label={t("dlg.fTitle")}>
<Input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} required />
</Field>
<Field label={t("dlg.fTopic")} hint={t("dlg.topicHint")}>
<div className="relative">
<Tag size={15} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<Input className="pl-10" value={topic} onChange={(e) => setTopic(e.target.value)} maxLength={120} placeholder={t("dlg.noTopic")} />
</div>
</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("common.save")}</Button>
</div>
</form>
</Modal>
);
}
/* ---------- Key recovery ---------- */
export function RecoverDialog({ open, onClose, from }: { open: boolean; onClose: () => void; from?: string }) {
const { t } = useT();
const [username, setUsername] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setUsername("");
setSent(false);
setError("");
}
}, [open ]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
await api.recover(username.trim().toLowerCase());
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title={t("dlg.recoverTitle")} icon={Mail} wide>
{sent ? (
<div className="space-y-4">
<p className="text-sm opacity-80">
{t("dlg.recoverSent", {
user: username.trim().toLowerCase() || "…",
fromPart: from ? t("dlg.recoverSentFrom", { from }) : "",
})}
</p>
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm opacity-90 dark:bg-amber-500/10">
{t("dlg.spamNote")}
</p>
<p className="text-sm opacity-70">
{t("dlg.noMailNote")}
</p>
<div className="flex justify-end">
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm opacity-70">
{t("dlg.recoverIntro")}
</p>
<Field label={t("common.username")}>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required />
</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("dlg.sendLink")}</Button>
</div>
</form>
)}
</Modal>
);
}
/* ---------- File preview (pre-transcription review) ---------- */
export function FilePreviewModal({
preview,
busy,
onClose,
onAttach,
}: {
preview: FilePreview | null;
busy: boolean;
onClose: () => void;
onAttach: (text: string) => void;
}) {
const { t } = useT();
const [text, setText] = useState("");
useEffect(() => {
if (preview) setText(preview.text);
}, [preview]);
const isOcr = preview?.kind === "ocr";
return (
<Modal
open={preview !== null}
onClose={onClose}
title={isOcr ? t("dlg.reviewOcr") : t("dlg.reviewText")}
icon={isOcr ? ScanText : FileText}
wide
>
{busy ? (
<p className="flex items-center gap-2 py-8 text-sm opacity-70">
<Spinner size={16} />
{isOcr ? t("dlg.transcribing") : t("dlg.uploadingTools")}
</p>
) : (
<div className="space-y-3">
<p className="rounded-2xl bg-accent-600/10 px-4 py-2.5 text-sm opacity-90">
{isOcr ? t("dlg.ocrExpl") : t("dlg.textExpl")}
</p>
{preview?.truncated && (
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm">{t("dlg.truncWarn")}</p>
)}
<textarea
rows={10}
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full resize-y rounded-2xl border border-stone-200 bg-stone-50 p-4 font-mono text-[13px] leading-relaxed outline-none focus:border-accent-500 dark:border-zinc-700 dark:bg-zinc-800"
/>
<div className="flex items-center justify-between">
<span className="text-xs opacity-60">{preview?.name} · {t("dlg.chars", { n: text.length.toLocaleString() })}</span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" onClick={() => onAttach(text)} disabled={!text.trim()}>{t("dlg.attachBtn")}</Button>
</div>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Self-registration ---------- */
export function RegisterDialog({
open,
onClose,
onDone,
}: {
open: boolean;
onClose: () => void;
onDone: (username: string, key: string) => void;
}) {
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("");
useEffect(() => {
if (open) {
setUsername("");
setDisplayName("");
setEmail("");
setKey(null);
setError("");
}
}, [open ]);
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);
}
}
return (
<Modal open={open} onClose={onClose} title={t("dlg.registerTitle")} icon={UserPlus}>
{key ? (
<div className="space-y-4">
<p className="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>
<div className="flex justify-end">
<Button size="sm" onClick={() => { onDone(username.trim().toLowerCase(), key); onClose(); }}>{t("dlg.regContinue")}</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<Field label={t("common.username")}>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" maxLength={32} required />
</Field>
<Field label={t("common.displayName")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} />
</Field>
<Field label={t("dlg.emailOpt")}>
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</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("login.register")}</Button>
</div>
</form>
)}
</Modal>
);
}
/* ---------- Share ---------- */
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
const { t } = useT();
const [url, setUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!conv) {
setUrl(null);
return;
}
setBusy(true);
setError("");
api.share(conv.id)
.then((r) => setUrl(window.location.origin + r.url))
.catch((err) => setError(err instanceof Error ? err.message : t("dlg.reqFailed")))
.finally(() => setBusy(false));
}, [conv]);
async function unshare() {
if (!conv) return;
await api.unshare(conv.id);
onClose();
}
return (
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> {t("dlg.shareCreating")}</p>}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{url && (
<div className="space-y-4">
<p className="text-sm opacity-70">{t("dlg.shareIntro", { title: conv?.title ?? "" })}</p>
<div className="flex items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-zinc-700 dark:bg-zinc-800">
<Link2 size={15} className="shrink-0 opacity-50" />
<span className="min-w-0 flex-1 truncate">{url}</span>
<CopyButton text={url} />
</div>
<div className="flex justify-between">
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Settings (profile) ---------- */
export function SettingsModal({
user,
onClose,
onSaved,
onKeyRotated,
}: {
user: User | null;
onClose: () => void;
onSaved: (u: User) => void;
onKeyRotated?: () => void;
}) {
const { t } = useT();
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [email, setEmail] = useState("");
const [theme, setTheme] = useState("auto");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [dragOver, setDragOver] = useState(false);
const [search, setSearch] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
// Two-column settings: categories on the left, content on the right.
// Searching filters the category list and jumps to the first match.
const CATS = [
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] },
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
] as const;
type CatId = (typeof CATS)[number]["id"];
const [cat, setCat] = useState<CatId>("profile");
const visibleCats = CATS.filter((c) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return c.title.toLowerCase().includes(q) || c.keywords.some((k) => k.includes(q));
});
const activeCat = visibleCats.some((c) => c.id === cat) ? cat : (visibleCats[0]?.id ?? "profile");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setEmail(user.email);
setTheme(user.theme);
setError("");
setUploadError("");
setSearch("");
}
}, [user]);
async function uploadFile(f: File) {
if (!user) return;
if (f.size > 5 * 1024 * 1024) {
setUploadError(t("sec.avatarTooBig"));
return;
}
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
setUploadError(t("sec.avatarType"));
return;
}
setUploading(true);
setUploadError("");
try {
const res = await api.uploadAvatar(user.id, f);
setAvatarUrl(res.url); // applied when you press Save
// Same URL path every upload -> bump the CDN version so every <Avatar>
// preview (this modal, sidebar, chat bubbles) re-fetches immediately.
bumpCdnVersion();
} catch (err) {
setUploadError(err instanceof Error ? err.message : t("attach.uploadFailed"));
} finally {
setUploading(false);
}
}
async function save(e: React.FormEvent) {
e.preventDefault();
if (!user || busy) return;
setBusy(true);
setError("");
try {
const res = await api.updateMe({ displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
onSaved(res.user);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t("common.saveFailed"));
} finally {
setBusy(false);
}
}
return (
<Modal open={user !== null} onClose={onClose} title={t("settings.title")} icon={Settings2} wide>
<form onSubmit={save} className="space-y-4">
<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={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("settings.searchPh")}
aria-label={t("settings.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"
/>
{search && (
<button
type="button"
aria-label={t("settings.clearSearch")}
onClick={() => setSearch("")}
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 flex-col gap-4 sm:flex-row">
<nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
{visibleCats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCat(c.id)}
aria-current={activeCat === c.id}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-2xl px-3.5 py-2.5 text-sm transition",
activeCat === c.id
? "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",
)}
>
<c.icon size={16} className="opacity-70" />
{c.title}
</button>
))}
</nav>
<div className="min-w-0 flex-1 space-y-4">
{activeCat === "profile" && (
<SettingsPane>
<div className="flex items-center gap-3">
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{displayName || "…"}</p>
<p className="truncate text-xs opacity-60">@{user?.username}</p>
</div>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.avatarUpload")}</span>
<div
role="button"
tabIndex={0}
aria-label={t("settings.avatarUpload")}
onClick={() => fileRef.current?.click()}
onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) uploadFile(f);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-3xl border-2 border-dashed px-4 py-3.5 transition",
dragOver
? "border-accent-500 bg-accent-500/10"
: "border-stone-200 hover:border-accent-500/60 hover:bg-stone-50 dark:border-zinc-700 dark:hover: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">
{uploading ? <Spinner size={17} /> : <ImagePlus size={17} />}
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="block font-medium">{uploading ? t("settings.uploading") : dragOver ? t("settings.dropActive") : t("settings.dropIdle")}</span>
<span className="block text-xs opacity-60">{t("settings.avatarHint")}</span>
</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadFile(f);
e.target.value = "";
}}
/>
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
</div>
<Field label={t("common.displayName")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
</Field>
<Field label={t("settings.email")} hint={t("settings.emailHint")}>
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field>
</SettingsPane>
)}
{activeCat === "appearance" && (
<SettingsPane>
<Field label={t("common.theme")}>
<Picker
ariaLabel={t("common.theme")}
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: t("common.themeAutoFull") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
]}
/>
</Field>
<Field label={t("settings.language")} hint={t("settings.languageHint")}>
<LangPicker align="left" />
</Field>
<AccentSection />
</SettingsPane>
)}
{activeCat === "features" && (
<SettingsPane>
<p className="-mb-2 text-xs opacity-60">{t("settings.featHint")}</p>
<FeaturesSection />
</SettingsPane>
)}
{activeCat === "security" && (
<SettingsPane>
<SecuritySection onKeyRotated={onKeyRotated} />
</SettingsPane>
)}
</div>
</div>
{search.trim() && visibleCats.length === 0 && (
<p className="px-2 py-4 text-center text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
)}
{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("common.save")}</Button>
</div>
</form>
</Modal>
);
}
/* ---------- Settings pane (unified glass card for every category) ---------- */
function SettingsPane({ children }: { children: React.ReactNode }) {
// Single unified surface for every settings section: flat matte card with
// one soft shadow — identical gloss on profile, appearance and features.
return (
<section className="space-y-4 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 shadow-sm shadow-stone-900/5 dark:border-zinc-800 dark:bg-zinc-800/60 dark:shadow-black/20">
{children}
</section>
);
}
/* ---------- Accent color (settings) ---------- */
function AccentSection() {
const { t } = useT();
const [state, setState] = useState<AccentState>(() => currentAccent());
function pick(presetId: string) {
const next = { ...state, presetId };
setState(next);
applyAccent(next);
}
function pickCustom(hex: string) {
const next = { presetId: "custom", customHex: hex };
setState(next);
previewAccent(hex);
}
return (
<div>
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.accent")}</span>
<p className="mb-2 text-xs opacity-60">{t("settings.accentHint")}</p>
<div className="flex flex-wrap items-center gap-2">
{ACCENT_PRESETS.map((p) => (
<button
key={p.id}
type="button"
title={t(`accent.preset.${p.id}` as StringKey)}
aria-label={t("settings.accentAria", { label: t(`accent.preset.${p.id}` as StringKey) })}
onClick={() => pick(p.id)}
className={cn(
"h-8 w-8 rounded-full border-2 transition active:scale-90",
state.presetId === p.id
? "border-stone-900 dark:border-white"
: "border-transparent hover:scale-110",
)}
style={{ backgroundColor: p.base }}
/>
))}
<label
className={cn(
"relative grid h-8 w-8 cursor-pointer place-items-center overflow-hidden rounded-full border-2 transition active:scale-90",
state.presetId === "custom"
? "border-stone-900 dark:border-white"
: "border-transparent hover:scale-110",
)}
style={{ background: state.presetId === "custom" ? state.customHex : undefined }}
title={t("settings.customColor")}
>
<Sparkles size={14} className={cn("text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]", state.presetId === "custom" && "opacity-0")} />
<input
type="color"
aria-label={t("settings.customAria")}
value={state.presetId === "custom" ? state.customHex : "#10b981"}
onChange={(e) => pickCustom(e.target.value)}
onBlur={() => applyAccent(state)} // persist the last previewed value
className="absolute inset-0 cursor-pointer opacity-0"
/>
</label>
</div>
</div>
);
}
/* ---------- Features (settings) ---------- */
function FeaturesSection() {
const { t } = useT();
const flags = useFeatures();
return (
<div>
<div className="space-y-1">
{FEATURES.map((f) => (
<label
key={f.key}
className={cn(
"flex items-center gap-3 rounded-2xl px-3 py-2 transition",
f.ready ? "cursor-pointer hover:bg-stone-100 dark:hover:bg-zinc-800" : "opacity-50",
)}
>
<Toggle
checked={!!flags[f.key]}
disabled={!f.ready}
label={t(`feat.${f.key}.label` as StringKey)}
onChange={(v) => setFeature(f.key, v)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium">
{t(`feat.${f.key}.label` as StringKey)}
{!f.ready && <span className="ml-1.5 text-xs opacity-60">{t("feat.soon")}</span>}
</span>
<span className="block text-xs opacity-60">{t(`feat.${f.key}.hint` as StringKey)}</span>
</span>
</label>
))}
</div>
</div>
);
}
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
const { t } = useT();
const [totpOn, setTotpOn] = useState<boolean | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [rotatedKey, setRotatedKey] = useState<string | null>(null);
const [armingRotate, setArmingRotate] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
api.totpStatus().then((r) => setTotpOn(r.enabled)).catch(() => setTotpOn(false));
}, []);
async function rotate() {
if (!armingRotate) {
setArmingRotate(true);
return;
}
setBusy(true);
setError("");
try {
const res = await api.rotateKey();
setRotatedKey(res.key);
setArmingRotate(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("sec.rotationFailed"));
} finally {
setBusy(false);
}
}
async function startTotp() {
setBusy(true);
setError("");
try {
const res = await api.totpSetup();
setSecret(res.secret);
setOtpauthUrl(res.otpauthUrl);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : t("sec.setupFailed"));
} finally {
setBusy(false);
}
}
async function confirmTotp() {
if (!secret || code.replace(/\D/g, "").length !== 6) return;
setBusy(true);
setError("");
try {
await api.totpVerify(secret, code.replace(/\D/g, ""));
setTotpOn(true);
setSecret(null);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : t("login.totpInvalid"));
} finally {
setBusy(false);
}
}
async function disableTotp() {
if (code.replace(/\D/g, "").length !== 6) return;
setBusy(true);
setError("");
try {
await api.totpDisable(code.replace(/\D/g, ""));
setTotpOn(false);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : t("login.totpInvalid"));
} finally {
setBusy(false);
}
}
async function removeAccount() {
setBusy(true);
setError("");
try {
await api.deleteMe();
setConfirmDelete(false);
onKeyRotated?.();
} catch (err) {
setError(err instanceof Error ? err.message : t("sec.deleteFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-5">
<div>
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> {t("sec.accessKey")}</p>
<p className="mb-2 text-xs opacity-60">{t("sec.accessKeyHint")}</p>
<Button size="sm" variant="secondary" disabled={busy} onClick={rotate}>
{armingRotate ? t("sec.rotateConfirm") : t("sec.rotate")}
</Button>
</div>
<div>
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><ShieldCheck size={14} className="opacity-60" /> {t("sec.totp")}</p>
{totpOn === null && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("sec.totpChecking")}</p>}
{totpOn === false && !secret && (
<>
<p className="mb-2 text-xs opacity-60">{t("sec.totpAddHint")}</p>
<Button size="sm" variant="secondary" disabled={busy} onClick={startTotp}>{t("sec.totpEnable")}</Button>
</>
)}
{secret && (
<div className="space-y-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
<p className="text-xs opacity-70">{t("sec.totpSecretHint")}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-xl bg-stone-100 px-3 py-2 font-mono text-sm dark:bg-zinc-800">{secret}</code>
<CopyButton text={secret} />
</div>
<a href={otpauthUrl} className="block truncate text-xs text-accent-700 underline-offset-2 hover:underline dark:text-accent-400">{otpauthUrl}</a>
<div className="flex gap-2">
<Input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="000000"
aria-label={t("login.totpAria")}
inputMode="numeric"
className="text-center tracking-[0.4em]"
/>
<Button size="sm" disabled={busy || code.length !== 6} onClick={confirmTotp}>{t("sec.totpConfirm")}</Button>
</div>
</div>
)}
{totpOn === true && (
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-36 flex-1">
<Field label={t("sec.totpDisableLabel")}>
<Input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="000000"
aria-label={t("login.totpAria")}
inputMode="numeric"
className="text-center tracking-[0.4em]"
/>
</Field>
</div>
<Button size="sm" variant="secondary" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
{t("sec.totpDisable")}
</Button>
</div>
)}
</div>
<div className="flex items-center gap-3 rounded-2xl px-1 py-1 opacity-50">
<Fingerprint size={16} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{t("sec.passkeys")} <span className="ml-1 text-xs opacity-60">{t("feat.soon")}</span></span>
<span className="block text-xs opacity-60">{t("sec.passkeysHint")}</span>
</span>
</div>
<div className="rounded-2xl border border-red-500/30 p-3">
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400">
<Trash2 size={14} /> {t("sec.danger")}
</p>
<p className="mb-2 text-xs opacity-60">{t("sec.dangerHint")}</p>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
{t("sec.deleteMe")}
</Button>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Modal open={rotatedKey !== null} onClose={() => {}} title={t("sec.newKeyTitle")} icon={KeyRound}>
<div className="space-y-4">
<p className="text-sm opacity-80">{t("sec.newKeyHint")}</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 font-mono text-sm dark:bg-zinc-800">{rotatedKey}</code>
{rotatedKey && <CopyButton text={rotatedKey} />}
</div>
<Button size="sm" className="w-full" onClick={() => onKeyRotated?.()}>{t("sec.newKeyDone")}</Button>
</div>
</Modal>
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={removeAccount}
title={t("sec.deleteTitle")}
message={t("sec.deleteMsg")}
confirmLabel={t("sec.deleteForever")}
/>
</div>
);
}
-614
View File
@@ -1,614 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
import { LANGS, setLang, useT, type LangId } from "../lib/i18n.ts";
/* ---------- Button ---------- */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "gradient" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg" | "icon";
};
export function Button({ variant = "primary", size = "md", type = "button", className, ...props }: ButtonProps) {
return (
<button
type={type}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" && "px-3.5 py-1.5 text-sm",
size === "md" && "px-5 py-2.5 text-sm",
size === "lg" && "px-6 py-3 text-base",
size === "icon" && "h-9 w-9",
variant === "primary" && "bg-accent-600 text-white shadow-sm hover:bg-accent-500 dark:bg-accent-500 dark:hover:bg-accent-400 dark:text-zinc-950",
variant === "gradient" && "bg-gradient-to-r from-accent-600 to-teal-500 text-white shadow-lg shadow-accent-600/25 hover:from-accent-500 hover:to-teal-400 dark:from-accent-500 dark:to-teal-400 dark:text-zinc-950",
variant === "secondary" && "border border-stone-200 bg-white hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800",
variant === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
className,
)}
{...props}
/>
);
}
export function IconButton({ className, title, ...props }: ButtonProps & { title: string }) {
return <Button variant="ghost" size="icon" title={title} aria-label={title} className={cn("rounded-full", className)} {...props} />;
}
/* ---------- Inputs ---------- */
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium opacity-80">{label}</span>
{children}
{hint && <span className="mt-1 block text-xs opacity-60">{hint}</span>}
</label>
);
}
export function Input({ variant = "default", className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { variant?: "default" | "soft" }) {
return (
<input
className={cn(
"w-full text-sm outline-none transition placeholder:text-stone-400 dark:placeholder:text-zinc-500",
variant === "default" &&
"rounded-2xl border border-stone-200 bg-white px-4 py-2.5 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20 dark:border-zinc-700 dark:bg-zinc-900",
// soft: onboarding-style pill field, blends into rounded cards
variant === "soft" &&
"rounded-full border border-transparent bg-stone-100 px-5 py-3 focus:border-accent-500/60 focus:bg-white focus:ring-4 focus:ring-accent-500/15 dark:bg-zinc-800/80 dark:focus:bg-zinc-900",
className,
)}
{...props}
/>
);
}
/* ---------- Picker (framework-styled dropdown, replaces native <select>) ---------- */
export interface PickerOption {
value: string;
label: string;
hint?: string;
}
export interface PickerGroup {
group: string;
options: PickerOption[];
}
export function Picker({
value,
onChange,
options,
groups,
placeholder,
ariaLabel,
disabled,
icon: Icon,
className,
align = "right",
}: {
value: string;
onChange: (v: string) => void;
options?: PickerOption[];
groups?: PickerGroup[];
placeholder?: string;
ariaLabel: string;
disabled?: boolean;
icon?: LucideIcon;
className?: string;
align?: "left" | "right";
}) {
const { t } = useT();
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 208 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
const shownPlaceholder = placeholder ?? t("common.select");
// Position the popover in viewport coords (rendered in a portal so no
// ancestor overflow — e.g. Modal's overflow-y-auto — can clip it).
// Re-measured live so the popover stays anchored to its field.
const updatePos = useCallback(() => {
const el = btnRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return false; // not laid out yet
const width = Math.min(Math.max(r.width, 208), 288);
let left = align === "right" ? r.right - width : r.left;
left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
// Use the popover's real height (not the max-h-72 cap) so short lists
// (e.g. 3 theme options) stay glued under the button instead of flipping
// above with a visible gap.
const popH = Math.min(popRef.current?.offsetHeight || 288, 288);
const below = r.bottom + 8;
const top = below + popH > window.innerHeight && r.top - popH - 8 > 8
? Math.max(8, r.top - popH - 8) // flip above when there is no room below
: below;
setPos({ top, left, width });
return true;
}, [align]);
useLayoutEffect(() => {
if (!open) return;
updatePos();
// Second pass after paint: catches late layout shifts (fonts, images).
const raf = requestAnimationFrame(() => updatePos());
return () => cancelAnimationFrame(raf);
}, [open, updatePos]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onScroll = (e: Event) => {
if (popRef.current?.contains(e.target as Node)) return; // scrolling inside the list
updatePos(); // follow the anchor instead of closing
};
const onResize = () => updatePos();
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, updatePos]);
return (
<div className={className}>
<button
ref={btnRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"flex max-w-56 items-center gap-2 rounded-full border border-stone-200 bg-white py-2 pl-3.5 pr-3 text-sm font-medium shadow-sm transition",
"hover:border-accent-500/60 hover:shadow disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-zinc-700 dark:bg-zinc-900",
open && "border-accent-500 ring-2 ring-accent-500/20",
)}
>
{Icon && <Icon size={15} className="shrink-0 text-accent-600 dark:text-accent-400" />}
<span className="truncate">{selected?.label ?? shownPlaceholder}</span>
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
</button>
{open && createPortal(
<>
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
<div
ref={popRef}
role="listbox"
aria-label={ariaLabel}
className="fixed z-50 max-h-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{all.map((g) => (
<div key={g.group || "default"}>
{g.group && (
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider opacity-50">{g.group}</p>
)}
{g.options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
role="option"
aria-selected={active}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition",
active
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{o.label}</span>
{o.hint && <span className="block truncate text-xs opacity-50">{o.hint}</span>}
</span>
{active && <Check size={15} className="shrink-0 text-accent-600 dark:text-accent-400" />}
</button>
);
})}
</div>
))}
</div>
</>,
document.body,
)}
</div>
);
}
/* ---------- Language picker (framework-styled dropdown over LANGS) ---------- */
export function LangPicker({ align = "right" }: { align?: "left" | "right" }) {
const { t, lang } = useT();
return (
<Picker
ariaLabel={t("settings.language")}
value={lang}
onChange={(v) => setLang(v as LangId)}
align={align}
options={LANGS.map((l) => ({ value: l.id, label: l.label }))}
/>
);
}
/* ---------- Modal / Dialog ---------- */
export function Modal({
open,
onClose,
title,
icon,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
icon?: LucideIcon;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
// Bottom-sheet drag state (mobile): pull the handle down to dismiss.
const [dragY, setDragY] = useState(0);
const dragStart = useRef<number | null>(null);
useEffect(() => {
if (!open) {
setDragY(0);
dragStart.current = null;
}
}, [open ]);
const { t } = useT();
if (!open) return null;
const Icon = icon;
// Portal to <body>: ancestors with backdrop-blur / transforms (e.g. the
// login card) become containing blocks for fixed elements and would trap
// the dialog inside them. Portaling keeps every modal viewport-centered.
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
className={cn(
"relative max-h-[92vh] w-full overflow-y-auto rounded-b-none rounded-t-[1.75rem] border border-stone-200 bg-white p-6 pt-3 shadow-2xl sm:max-h-[85vh] sm:rounded-3xl sm:pt-6 dark:border-zinc-700 dark:bg-zinc-900",
wide ? "sm:max-w-2xl" : "sm:max-w-md",
)}
style={dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined}
>
{/* drag handle — mobile only */}
<div
className="sticky top-0 z-10 -mx-6 px-6 pb-3 pt-1 sm:hidden"
onTouchStart={(e) => { dragStart.current = e.touches[0]!.clientY; }}
onTouchMove={(e) => {
if (dragStart.current === null) return;
const dy = e.touches[0]!.clientY - dragStart.current;
setDragY(dy > 0 ? dy : 0);
}}
onTouchEnd={() => {
if (dragY > 110) onClose(); // snap shut past threshold…
setDragY(0); // …otherwise snap back
dragStart.current = null;
}}
>
<div className="mx-auto h-1.5 w-12 rounded-full bg-stone-300 dark:bg-zinc-600" />
</div>
<div className="mb-4 flex items-center gap-2.5">
{Icon && (
<span className="grid h-9 w-9 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<Icon size={18} />
</span>
)}
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} aria-label={t("common.close")} className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<X size={18} />
</button>
</div>
{children}
</div>
</div>,
document.body,
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
}) {
const { t } = useT();
return (
<Modal open={open} onClose={onClose} title={title}>
<p className="text-sm opacity-80">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel ?? t("common.delete")}</Button>
</div>
</Modal>
);
}
/* ---------- Context menu ---------- */
export interface MenuItem {
icon: LucideIcon;
label: string;
danger?: boolean;
onClick: () => void;
}
export function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ x, y });
useEffect(() => {
const el = ref.current;
if (el) {
const r = el.getBoundingClientRect();
setPos({
x: Math.min(x, window.innerWidth - r.width - 8),
y: Math.min(y, window.innerHeight - r.height - 8),
});
}
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [x, y, onClose]);
// Rendered in a portal: the sidebar's backdrop-blur would otherwise turn
// into a containing block, breaking the fullscreen overlay and offsetting
// the menu (text showing through / misplaced over the composer).
return createPortal(
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }}>
<div
ref={ref}
className="fixed z-50 w-52 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1.5 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ left: pos.x, top: pos.y }}
onClick={(e) => e.stopPropagation()}
>
{items.map((it) => (
<button
key={it.label}
onClick={() => { it.onClick(); onClose(); }}
className={cn(
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-sm transition",
it.danger
? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
: "hover:bg-stone-100 dark:hover:bg-zinc-800",
)}
>
<it.icon size={16} />
{it.label}
</button>
))}
</div>
</div>,
document.body,
);
}
/* ---------- Avatar ---------- */
// Uploaded avatars reuse the same /cdn/avatar/<userId> path, so the <img> URL
// never changes and browsers keep serving the stale cached file. A global
// version counter (bumped after each successful upload) cache-busts every
// /cdn/ URL rendered by <Avatar>, refreshing previews app-wide instantly.
let cdnVersion = 0;
const cdnVersionListeners = new Set<() => void>();
/** Call after a successful CDN upload so every <Avatar> re-fetches the file. */
export function bumpCdnVersion(): void {
cdnVersion++;
cdnVersionListeners.forEach((l) => l());
}
function useCdnVersion(): number {
const [v, setV] = useState(cdnVersion);
useEffect(() => {
const l = () => setV(cdnVersion);
cdnVersionListeners.add(l);
return () => { cdnVersionListeners.delete(l); };
}, []);
return v;
}
function withCdnVersion(url: string, v: number): string {
if (!url.startsWith("/cdn/")) return url;
return `${url}${url.includes("?") ? "&" : "?"}v=${v}`;
}
const GRADIENTS = [
"from-accent-500 to-teal-600",
"from-violet-500 to-purple-600",
"from-amber-500 to-orange-600",
"from-sky-500 to-cyan-600",
"from-rose-500 to-pink-600",
];
export function Avatar({ name, url, size = 36 }: { name: string; url?: string; size?: number }) {
const cdnV = useCdnVersion();
if (url) {
return <img src={withCdnVersion(url, cdnV)} alt={name} width={size} height={size} className="rounded-full object-cover" style={{ width: size, height: size }} />;
}
const initial = (name.trim()[0] || "?").toUpperCase();
const g = GRADIENTS[(name.charCodeAt(0) || 0) % GRADIENTS.length];
return (
<span
className={cn("grid shrink-0 place-items-center rounded-full bg-gradient-to-br font-semibold text-white", g)}
style={{ width: size, height: size, fontSize: size * 0.42 }}
aria-label={name}
>
{initial}
</span>
);
}
/* ---------- Brand logo (single declaration — import anywhere) ---------- */
/**
* The KisAssistant flower mark. Petals follow the runtime accent color,
* heart stays amber — same artwork as the loading screen and favicon.
* `dynamic` controls the bloom animation: pass false for a static,
* full-bloom mark (e.g. thread bars and headers).
*/
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
const petals = [0, 60, 120, 180, 240, 300];
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className={cn("shrink-0", className)}
role="img"
aria-label="KisAssistant"
>
{petals.map((r, i) => (
<g key={r} transform={`rotate(${r} 50 50)`}>
<ellipse
cx="50"
cy="25"
rx="13"
ry="21"
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
/>
</g>
))}
<circle cx="50" cy="50" r="10" className={dynamic ? "flower-core fill-amber-400" : "fill-amber-400"} />
</svg>
);
}
/** Assistant identity: static flower mark on a soft circle (message avatars). */
export function AssistantAvatar({ size = 32 }: { size?: number }) {
return (
<span
className="grid shrink-0 place-items-center rounded-full bg-accent-600/10"
style={{ width: size, height: size }}
aria-label="KisAssistant"
>
<FlowerMark size={Math.round(size * 0.78)} dynamic={false} />
</span>
);
}
/* ---------- Switch (feature toggle) ---------- */
export function Switch({ checked, onChange, label, disabled = false }: { checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return <Toggle checked={checked} onChange={onChange} label={label} disabled={disabled} />;
}
/* ---------- Misc ---------- */
export function Spinner({ size = 18 }: { size?: number }) {
return <Loader2 size={size} className="animate-spin" />;
}
/** Accessible on/off switch (accent colored when on). Flex-based so the knob
* is always perfectly centered — no border/padding math to break. */
export function Toggle({
checked,
onChange,
label,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"flex h-6 w-11 shrink-0 items-center rounded-full px-0.5 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 disabled:cursor-not-allowed disabled:opacity-40",
checked ? "bg-accent-600 dark:bg-accent-500" : "bg-stone-300 dark:bg-zinc-700",
)}
>
<span
className={cn(
"h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 dark:bg-zinc-100",
checked ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
);
}
export function CopyButton({ text, label }: { text: string; label?: string }) {
const { t } = useT();
const [done, setDone] = useState(false);
return (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1500);
});
}}
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
>
{done ? <Check size={13} className="text-accent-500" /> : <Copy size={13} />}
{done ? t("common.copied") : (label ?? t("common.copy"))}
</button>
);
}
/* ---------- Loading screen ---------- */
/** Animated blooming flower shown while the app boots (themed, mobile-safe). */
export function LoadingScreen() {
const { t } = useT();
return (
<div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
<div className="flex flex-col items-center gap-4 px-6 text-center">
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
</div>
</div>
);
}
-154
View File
@@ -1,154 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Accent palette defaults (emerald). Overridden at runtime by lib/accent.ts
from the user's saved accent color / custom pick. Values are RGB triplets. */
:root {
--ka-accent-50: 236 253 245;
--ka-accent-100: 209 250 229;
--ka-accent-200: 167 243 208;
--ka-accent-300: 110 231 183;
--ka-accent-400: 52 211 153;
--ka-accent-500: 16 185 129;
--ka-accent-600: 5 150 105;
--ka-accent-700: 4 120 87;
--ka-accent-800: 6 95 70;
--ka-accent-900: 6 78 59;
}
html, body, #root {
height: 100%;
}
/* Mobile browser chrome (URL bar) shrinks the visible area — dynamic
viewport units keep the app glued to the real screen height. */
@supports (height: 100dvh) {
html, body, #root {
height: 100dvh;
}
}
body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
/* Kill the 300ms tap delay, keep gestures app-like. */
touch-action: manipulation;
overscroll-behavior-y: none;
}
/* slim scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: rgb(0 0 0 / 0.2) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 0.2);
border-radius: 999px;
}
/* markdown content */
.md p:first-child { margin-top: 0; }
.md p:last-child { margin-bottom: 0; }
.md pre {
@apply relative overflow-x-auto rounded-2xl border border-stone-200 bg-stone-900 p-4 text-[13.5px] leading-relaxed text-stone-100 dark:border-zinc-700;
}
.md pre code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: none !important;
padding: 0 !important;
}
.md :not(pre) > code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@apply rounded-md bg-stone-200/70 px-1.5 py-0.5 text-[13px] dark:bg-zinc-800;
}
.md table {
@apply w-full border-collapse overflow-hidden rounded-xl text-sm;
}
.md th, .md td {
@apply border border-stone-200 px-3 py-1.5 dark:border-zinc-700;
}
.md th {
@apply bg-stone-200/50 dark:bg-zinc-800;
}
.md a {
@apply text-accent-600 underline dark:text-accent-400;
}
.md ul { @apply list-disc pl-5; }
.md ol { @apply list-decimal pl-5; }
.md blockquote {
@apply border-l-4 border-accent-500/50 pl-3 italic opacity-80;
}
.typing-cursor::after {
content: "▍";
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Loading flower: petals bloom one after another, core breathes. */
.flower-petal {
transform-box: fill-box;
transform-origin: center;
animation: bloom 1.8s ease-in-out infinite;
}
@keyframes bloom {
0%, 100% { transform: scale(0.55); opacity: 0.3; }
50% { transform: scale(1); opacity: 1; }
}
.flower-core {
transform-box: fill-box;
transform-origin: center;
animation: core-breathe 1.8s ease-in-out infinite;
}
@keyframes core-breathe {
0%, 100% { transform: scale(0.85); }
50% { transform: scale(1.15); }
}
/* Smooth entrance for full-screen loaders. */
@keyframes loading-in {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.loading-enter {
animation: loading-in 0.45s ease-out both;
}
.loading-dots::after {
content: "";
animation: dots 1.4s steps(4) infinite;
}
@keyframes dots {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
@media (prefers-reduced-motion: reduce) {
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
animation: none;
}
}
/* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px rgb(245 245 244) inset; /* stone-100, matches soft fields */
-webkit-text-fill-color: #1c1917; /* stone-900 */
caret-color: #1c1917;
}
.dark input:-webkit-autofill,
.dark input:-webkit-autofill:hover,
.dark input:-webkit-autofill:focus,
.dark input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px #27272a inset; /* zinc-800 */
-webkit-text-fill-color: #f4f4f5; /* zinc-100 */
caret-color: #f4f4f5;
}
-117
View File
@@ -1,117 +0,0 @@
/**
* Runtime accent color (settings → accent color). Predefined palettes plus a
* custom picker; shades 50–900 are derived from a single base hex via HSL and
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
* palette. Persisted per-browser (localStorage), like the feature flags.
*/
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900", string>;
export interface AccentPreset {
id: string;
label: string;
base: string; // hex, used as the 500 shade and to derive the ramp
}
export const ACCENT_PRESETS: AccentPreset[] = [
{ id: "emerald", label: "Emerald", base: "#10b981" },
{ id: "rose", label: "Rose", base: "#f43f5e" },
{ id: "blue", label: "Blue", base: "#3b82f6" },
{ id: "violet", label: "Violet", base: "#8b5cf6" },
{ id: "amber", label: "Yellow", base: "#f59e0b" },
{ id: "orange", label: "Orange", base: "#f97316" },
{ id: "teal", label: "Teal", base: "#14b8a6" },
{ id: "pink", label: "Pink", base: "#ec4899" },
];
const STORAGE_KEY = "ka:accent";
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
// Lightness ramps for 50→900 around a mid 500 (HSL L values in %).
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24,
};
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return { h: 160, s: 84, l: 52 }; // emerald fallback
const n = parseInt(m[1]!, 16);
const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
}
return { h, s: s * 100, l: l * 100 };
}
function hslToRgbTriplet(h: number, s: number, l: number): string {
const sn = s / 100, ln = l / 100;
const c = (1 - Math.abs(2 * ln - 1)) * sn;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = ln - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const to = (v: number) => Math.round((v + m) * 255);
return `${to(r)} ${to(g)} ${to(b)}`;
}
/** Build the 50–900 ramp from a base hex (HSL ramp, emerald-style). */
export function shadesFor(baseHex: string): AccentShades {
const { h, s } = hexToHsl(baseHex);
const out = {} as AccentShades;
for (const k of SHADE_KEYS) {
// Keep saturation lively at the light end, richer at the dark end.
const sat = k === "50" || k === "100" ? s * 0.9 : s;
out[k] = hslToRgbTriplet(h, sat, RAMP[k]);
}
return out;
}
export interface AccentState {
/** preset id, or "custom" */
presetId: string;
/** hex for custom (ignored for presets — they derive from their base) */
customHex: string;
}
export function currentAccent(): AccentState {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as AccentState | null;
if (raw && typeof raw.presetId === "string") return raw;
} catch { /* ignore */ }
return { presetId: "emerald", customHex: "#10b981" };
}
export function applyAccent(state: AccentState): void {
const preset = ACCENT_PRESETS.find((p) => p.id === state.presetId);
const base = preset ? preset.base : (/^#?[0-9a-f]{6}$/i.test(state.customHex) ? state.customHex : "#10b981");
const shades = shadesFor(base);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
/** Preview without persisting (used while the picker is open). */
export function previewAccent(baseHex: string): void {
const shades = shadesFor(baseHex);
const root = document.documentElement;
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
}
// Restore the saved accent on load.
if (typeof document !== "undefined") {
applyAccent(currentAccent());
}
-297
View File
@@ -1,297 +0,0 @@
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;
}
const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(t: string): void {
localStorage.setItem(TOKEN_KEY, t);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, opts: RequestInit = {}, auth = true): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth) {
const t = getToken();
if (t) headers["Authorization"] = `Bearer ${t}`;
}
const res = await fetch(path, { ...opts, headers: { ...headers, ...(opts.headers as Record<string, string> | undefined) } });
if (res.status === 401 && auth) {
clearToken();
if (!window.location.pathname.startsWith("/share/")) window.location.reload();
throw new ApiError(401, "Session expired, please log in again.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error || `Request failed (${res.status})`);
return data as T;
}
export const api = {
login: (username: string, key: string) =>
req<{ token: string; expiresAt: number; user: User } | { totpRequired: true; totpToken: string; username: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, key }),
}, false),
totpLogin: (totpToken: string, code: string) =>
req<{ token: string; expiresAt: number; user: User }>("/api/auth/totp", {
method: "POST",
body: JSON.stringify({ totpToken, code }),
}, false),
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
methods: () => req<{ recovery: boolean; from?: string; registration: boolean; accessRequest: boolean }>("/api/auth/methods", {}, false),
register: (u: { username: string; displayName?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/auth/register", { method: "POST", body: JSON.stringify(u) }, false),
recover: (username: string) =>
req<{ ok: boolean }>("/api/auth/recover", { method: "POST", body: JSON.stringify({ username }) }, false),
resetCheck: (token: string) => req<{ ok: boolean; username: string }>(`/api/auth/reset/${token}`, {}, false),
resetConsume: (token: string) => req<{ key: string }>(`/api/auth/reset/${token}`, { method: "POST" }, false),
me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
/** Rotate your own access key (old sessions revoked, key shown once). */
rotateKey: () => req<{ key: string }>("/api/me/key/rotate", { method: "POST" }),
/** Permanently delete your own account (chats, shares, sessions). */
deleteMe: () => req<{ ok: boolean }>("/api/me", { method: "DELETE" }),
/** TOTP two-factor self-service. */
totpStatus: () => req<{ enabled: boolean }>("/api/me/totp"),
totpSetup: () => req<{ secret: string; otpauthUrl: string }>("/api/me/totp/setup", { method: "POST" }),
totpVerify: (secret: string, code: string) =>
req<{ enabled: boolean }>("/api/me/totp/verify", { method: "POST", body: JSON.stringify({ secret, code }) }),
totpDisable: (code: string) =>
req<{ enabled: boolean }>("/api/me/totp", { method: "DELETE", body: JSON.stringify({ code }) }),
adminList: (p: { q?: string; sort?: string; filter?: string; page?: number; per?: number } = {}) => {
const qs = new URLSearchParams();
if (p.q) qs.set("q", p.q);
if (p.sort) qs.set("sort", p.sort);
if (p.filter) qs.set("filter", p.filter);
if (p.page) qs.set("page", String(p.page));
if (p.per) qs.set("per", String(p.per));
const s = qs.toString();
return req<{ users: User[]; total: number; page: number; perPage: number; pages: number }>(
`/api/admin/users${s ? `?${s}` : ""}`,
);
},
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings"),
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) =>
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
adminMailStatus: () =>
req<{ smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; pass: string; hasPass: boolean; from: string; appUrl: string } }>("/api/admin/mail"),
adminMailVerify: () => req<{ ok: boolean }>("/api/admin/mail/verify", { method: "POST", body: JSON.stringify({}) }),
adminMailTest: (to: string) =>
req<{ ok: boolean; messageId: string }>("/api/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
/** Search your old chats (titles, topics and message content). */
searchConvs: (q: string) =>
req<{ conversations: (Conversation & { snippet: string | null; snippetRole: string | null })[] }>(
`/api/conversations/search?q=${encodeURIComponent(q)}`,
),
createConv: (c: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>("/api/conversations", { method: "POST", body: JSON.stringify(c) }),
getConv: (id: string) => req<{ conversation: Conversation; messages: ChatMessage[] }>(`/api/conversations/${id}`),
patchConv: (id: string, patch: { title?: string; topic?: string; model?: string }) =>
req<{ conversation: Conversation }>(`/api/conversations/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteConv: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}`, { method: "DELETE" }),
share: (id: string) => req<{ publicId: string; url: string }>(`/api/conversations/${id}/share`, { method: "POST" }),
getShare: (id: string) => req<{ publicId: string; url: string } | { shared: false }>(`/api/conversations/${id}/share`),
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
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 }),
}),
ollamaPull: async (
name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch("/api/admin/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ name }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Pull failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (t) onProgress(JSON.parse(t));
}
}
},
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
const res = await fetch("/api/tools/ocr", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string; text?: string; truncated?: boolean; chars?: number };
if (!res.ok) throw new ApiError(res.status, data.error || `OCR failed (${res.status})`);
return { text: data.text ?? "", truncated: !!data.truncated, chars: data.chars ?? 0 };
},
/** Avatar upload to the local CDN. Returns the public /cdn/… URL. */
uploadAvatar: async (userId: string, file: File): Promise<{ url: string }> => {
const res = await fetch(`/cdn/avatar/${userId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
const data = (await res.json().catch(() => ({}))) as { error?: string; url?: string };
if (!res.ok) throw new ApiError(res.status, data.error || `Upload failed (${res.status})`);
return { url: data.url! };
},
/** Streaming chat. Calls onToken per token; resolves with the full text. */
chatStream: async (
args: { model: string; messages: ChatMessage[]; conversationId?: string },
onToken: (t: string) => void,
): Promise<string> => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ ...args, stream: true }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Chat failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let full = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop() ?? "";
for (const ev of events) {
const m = ev.match(/^event: (\w+)\ndata: ([\s\S]*)$/m);
if (!m) continue;
const [, event, data] = m as [string, string, string];
if (event === "token") {
const token = (JSON.parse(data) as { token: string }).token;
full += token;
onToken(token);
} else if (event === "error") {
throw new ApiError(502, (JSON.parse(data) as { error?: string }).error || "stream error");
}
}
}
return full;
},
};
-3
View File
@@ -1,3 +0,0 @@
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
-65
View File
@@ -1,65 +0,0 @@
import { useSyncExternalStore } from "react";
/**
* Per-browser feature flags (localStorage). Gate optional composer
* capabilities; kept client-side on purpose — they only change what the
* UI offers / what prompt decorations are sent, never auth or data access.
*/
export type FeatureKey = "thinking" | "attachments" | "deepSearch";
export const FEATURES: { key: FeatureKey; ready: boolean }[] = [
{ key: "thinking", ready: true },
{ key: "attachments", ready: true },
{ key: "deepSearch", ready: false },
];
const STORAGE_KEY = "ka:features";
const DEFAULTS: Record<FeatureKey, boolean> = {
thinking: false,
attachments: true,
deepSearch: false,
};
function load(): Record<FeatureKey, boolean> {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as Partial<Record<FeatureKey, boolean>>;
return { ...DEFAULTS, ...raw };
} catch {
return { ...DEFAULTS };
}
}
let flags = load();
const listeners = new Set<() => void>();
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(flags));
} catch {
// private mode etc. — flags just won't survive a reload
}
listeners.forEach((l) => l());
}
export function featureEnabled(key: FeatureKey): boolean {
return flags[key];
}
export function setFeature(key: FeatureKey, value: boolean) {
flags = { ...flags, [key]: value };
persist();
}
export function useFeatures(): Record<FeatureKey, boolean> {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
},
() => flags,
);
}
/** System message prepended to /api/chat when the "thinking" flag is on. */
export const THINKING_SYSTEM_PROMPT =
"Think step-by-step before answering. Start with a short 'Thoughts' section showing your reasoning, then give the final answer under an 'Answer' heading.";
File diff suppressed because it is too large Load Diff
-56
View File
@@ -1,56 +0,0 @@
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;
created_at: number;
updated_at: number;
}
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface DriverModel {
id: string;
name: string;
driver: string;
label: string;
}
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;
sharedAt: number;
messages: ChatMessage[];
}
-11
View File
@@ -1,11 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";
import { App } from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-29
View File
@@ -1,29 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
fontFamily: {
sans: ["Inter Variable", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
},
/* Accent palette backed by CSS variables so the user can recolor the
UI at runtime (settings → accent color). Defaults = emerald. */
colors: {
accent: {
50: "rgb(var(--ka-accent-50) / <alpha-value>)",
100: "rgb(var(--ka-accent-100) / <alpha-value>)",
200: "rgb(var(--ka-accent-200) / <alpha-value>)",
300: "rgb(var(--ka-accent-300) / <alpha-value>)",
400: "rgb(var(--ka-accent-400) / <alpha-value>)",
500: "rgb(var(--ka-accent-500) / <alpha-value>)",
600: "rgb(var(--ka-accent-600) / <alpha-value>)",
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
},
},
},
},
plugins: [],
};
-20
View File
@@ -1,20 +0,0 @@
{
"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"]
}
-17
View File
@@ -1,17 +0,0 @@
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: {
"/api": "http://localhost:3000",
},
},
});
-22
View File
@@ -1,22 +0,0 @@
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
-120
View File
@@ -1,120 +0,0 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.kisassistant;
# Backward-compatible tri-state: null (default) keeps the legacy behaviour
# (setting `domain` activates nginx), true forces it on, false forces it off.
nginxEnabled =
if cfg.enableNginx != null then cfg.enableNginx
else cfg.domain != null;
in {
options.services.kisassistant = {
enable = lib.mkEnableOption "KisAssistant private chat assistant";
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = ''
KisAssistant package built from this flake, e.g.
`kisassistant.packages.''${pkgs.system}.default`.
Required when the service is enabled.
'';
};
port = lib.mkOption { type = lib.types.port; default = 3000; };
host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; };
dataDir = lib.mkOption { type = lib.types.str; default = "/var/lib/kisassistant"; };
user = lib.mkOption { type = lib.types.str; default = "kisassistant"; };
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store).";
};
ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; };
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "nginx virtualHost name (e.g. chat.kisakay.com). Required when enableNginx is true.";
};
enableNginx = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Reverse-proxy the service through nginx with TLS + ACME.
- `true`: nginx is configured (requires `domain`).
- `false`: nginx is never configured, even if `domain` is set.
- `null` (default): legacy behaviour — nginx is configured iff `domain` is set.
'';
};
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;
} // 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).
path = [ pkgs.tesseract ];
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;";
};
};
};
};
}
-32
View File
@@ -1,32 +0,0 @@
{
"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"
},
"devDependencies": {
"@types/bun": "latest",
"@types/nodemailer": "^8.0.2"
}
}
-35
View File
@@ -1,35 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
/nix/store/l149xpcs2ywk0kl4l2lk0avddfviarnq-kisassistant-0.2.0
-114
View File
@@ -1,114 +0,0 @@
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
/**
* Access-request wishlist (registration approval queue) + minimal ticket
* system. Tables are created idempotently here so db.ts stays untouched.
*
* Flow: visitor reserves username+email with a motivation message ->
* ticket page at /review/<request-id> (UUID, unguessable — same pattern as
* public share links) -> admin triages in the AdminCenter (reviewing /
* accepted / refused, each with a reason) -> both sides exchange messages;
* every admin event emails the requester (notification-only).
*/
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
export interface AccessRequest {
id: string;
username: string;
email: string;
message: string;
status: AccessStatus;
reason: string;
created_at: number;
updated_at: number;
}
export interface AccessMessage {
id: number;
request_id: string;
author: "user" | "admin";
body: string;
created_at: number;
}
export function initAccessTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
}
export function createAccessRequest(opts: {
username: string;
email: string;
message: string;
}): AccessRequest {
const now = Date.now();
const row: AccessRequest = {
id: randomUUID(),
username: opts.username,
email: opts.email,
message: opts.message,
status: "pending",
reason: "",
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
return row;
}
export function getAccessRequest(id: string): AccessRequest | null {
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
}
/** Open (non-terminal) request already reserving this username or email. */
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
return getDb().query(
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
).get(username, email) as AccessRequest | null;
}
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
return getDb().query(
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
FROM access_requests r ORDER BY r.updated_at DESC`,
).all() as (AccessRequest & { message_count: number })[];
}
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
return getAccessRequest(id);
}
export function listAccessMessages(requestId: string): AccessMessage[] {
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
}
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
const now = Date.now();
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
return { id, request_id: requestId, author, body, created_at: now };
}
-115
View File
@@ -1,115 +0,0 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { config } from "./config.ts";
import {
createSession,
deleteSession,
getDb,
getSession,
getUserById,
getUserByUsername,
toPublicUser,
type PublicUser,
} from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser };
function sha256hex(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/** Compare two secrets in constant time (via their hashes so lengths match). */
export function secretsEqual(a: string, bHash: string): boolean {
const aHash = sha256hex(a);
const ab = Buffer.from(aHash, "utf8");
const bb = Buffer.from(bHash, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
export function hashSecret(s: string): string {
return sha256hex(s);
}
/** Fresh random per-account access key (No-KYC: admin hands it to the user). Shown only once. */
export function newAccessKey(): string {
return "ka_" + randomBytes(24).toString("base64url");
}
function checkAdminKey(candidate: string): boolean {
const a = createHash("sha256").update(candidate).digest();
const b = createHash("sha256").update(config.appPassword).digest();
return timingSafeEqual(a, b);
}
/**
* Login with { username, key }.
* - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key.
*/
export function login(username: string, key: string): PublicUser | null {
if (username === "admin") {
if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin");
if (!admin) return null;
return toPublicUser(admin);
}
const u = getUserByUsername(username);
if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u);
}
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt };
}
export function verifyToken(token: string): PublicUser | null {
const s = getSession(sha256hex(token));
if (!s) return null;
const u = getUserById(s.user_id);
if (!u) {
deleteSession(sha256hex(token));
return null;
}
return toPublicUser(u);
}
export function revokeToken(token: string): void {
deleteSession(sha256hex(token));
}
export function extractBearer(req: Request): string | null {
const h = req.headers.get("authorization");
if (!h) return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1]!.trim() : null;
}
// --- sliding-window rate limiter (per IP) for /api/auth/login ---
const attempts = new Map<string, number[]>();
export function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowStart = now - config.rateLimitWindowMs;
const list = (attempts.get(ip) ?? []).filter((t) => t > windowStart);
attempts.set(ip, list);
return list.length >= config.rateLimitMax;
}
export function recordAttempt(ip: string): void {
const list = attempts.get(ip) ?? [];
list.push(Date.now());
attempts.set(ip, list);
}
export function clearAttempts(ip: string): void {
attempts.delete(ip);
}
-178
View File
@@ -1,178 +0,0 @@
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);
}
-84
View File
@@ -1,84 +0,0 @@
function env(name: string, fallback = ""): string {
return process.env[name] ?? fallback;
}
function envBool(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v === undefined) return fallback;
return v === "1" || v.toLowerCase() === "true";
}
function envInt(name: string, fallback: number): number {
const v = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(v) ? v : fallback;
}
export const config = {
// "production" under the NixOS module / CI builds, "development" locally.
nodeEnv: env("NODE_ENV", "development"),
isProduction: env("NODE_ENV", "development") === "production",
port: envInt("PORT", 3000),
host: env("HOST", "127.0.0.1"),
appPassword: env("APP_PASSWORD", ""),
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
dataDir: env("DATA_DIR", "./data"),
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
ollamaHost: env("OLLAMA_HOST", "http://localhost:11434"),
ollamaEnabled: envBool("OLLAMA_ENABLED", true),
mistralApiKey: env("MISTRAL_API_KEY", ""),
mistralEnabled: envBool("MISTRAL_ENABLED", false),
deepseekApiKey: env("DEEPSEEK_API_KEY", ""),
deepseekEnabled: envBool("DEEPSEEK_ENABLED", false),
anthropicApiKey: env("ANTHROPIC_API_KEY", ""),
anthropicEnabled: envBool("ANTHROPIC_ENABLED", false),
openaiApiKey: env("OPENAI_API_KEY", ""),
openaiEnabled: envBool("OPENAI_ENABLED", false),
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"),
// Public docs wiki (served by the hosted git forge, e.g. Gitea wiki pages).
// The app redirects GET /wiki there (302) so the target stays configurable.
wikiUrl: env("WIKI_URL", "https://git.kisakay.com/k/chat/wiki"),
// Per-driver debug logging to stdout ([driver:name] lines).
// On by default in development, off unless asked for in production.
driverDebug: envBool("DRIVER_DEBUG", !env("NODE_ENV", "development")),
// Local file CDN (avatars today, more namespaces tomorrow).
// Dev default is a folder next to the codebase; NixOS module points it
// under the service state dir.
cdnDir: env("CDN_DIR", "./cdn"),
// OCR tool (tesseract binary).
ocrLang: env("OCR_LANG", "eng"),
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
// Key recovery via email (optional — empty SMTP_HOST disables it).
smtpHost: env("SMTP_HOST", ""),
smtpPort: envInt("SMTP_PORT", 587),
smtpSecure: envBool("SMTP_SECURE", false), // true = implicit TLS (port 465)
smtpUser: env("SMTP_USER", ""),
smtpPass: env("SMTP_PASS", ""),
smtpFrom: env("SMTP_FROM", "KisAssistant <[email protected]>"),
appUrl: env("APP_URL", "http://localhost:3000").replace(/\/$/, ""),
resetTtlMs: envInt("RESET_TTL_MIN", 60) * 60_000,
};
export function assertConfig(): void {
if (!config.appPassword) {
console.error("FATAL: APP_PASSWORD is not set. Copy .env.example to .env and set it.");
process.exit(1);
}
}
-404
View File
@@ -1,404 +0,0 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { config } from "./config.ts";
// --- row types ---
export interface UserRow {
id: string;
username: string;
display_name: string;
avatar_url: string;
theme: string;
email: string;
key_hash: string;
created_at: number;
}
export interface ConversationRow {
id: string;
user_id: string;
title: string;
topic: string;
model: string;
created_at: number;
updated_at: number;
}
export interface MessageRow {
id: number;
conv_id: string;
role: string;
content: string;
created_at: number;
}
export interface SessionRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export interface PublicUser {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: string;
email: string;
createdAt: number;
isAdmin: boolean;
}
// --- db handle ---
let db: Database | null = null;
export function getDb(): Database {
if (db) return db;
mkdirSync(config.dataDir, { recursive: true });
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Email column added after the initial schema — keep idempotent.
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
db.query(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
}
return db;
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
username: u.username,
displayName: u.display_name,
avatarUrl: u.avatar_url,
theme: u.theme,
email: u.email ?? "",
createdAt: u.created_at,
isAdmin: u.username === "admin",
};
}
// --- users ---
export function getUserByUsername(username: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
}
export function getUserById(id: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
}
export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
}
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
const d = getDb();
const row: UserRow = {
id: randomUUID(),
username: opts.username,
display_name: opts.displayName,
avatar_url: opts.avatarUrl,
theme: opts.theme,
email: opts.email,
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (patch.displayName !== undefined) { sets.push("display_name = ?"); vals.push(patch.displayName); }
if (patch.avatarUrl !== undefined) { sets.push("avatar_url = ?"); vals.push(patch.avatarUrl); }
if (patch.theme !== undefined) { sets.push("theme = ?"); vals.push(patch.theme); }
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
}
return getUserById(id);
}
export function deleteUser(id: string): void {
const d = getDb();
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
const txn = d.transaction((cid: string) => {
delMsg.run(cid);
delShare.run(cid);
delConv.run(cid);
});
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
}
// --- sessions ---
export function createSession(tokenHash: string, userId: string, expiresAt: number): void {
const now = Date.now();
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt);
}
export function getSession(tokenHash: string): SessionRow | null {
const d = getDb();
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
if (!s) return null;
if (s.expires_at <= Date.now()) {
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
return null;
}
return s;
}
export function deleteSession(tokenHash: string): void {
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
}
export function deleteUserSessions(userId: string): void {
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
// --- conversations ---
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow {
const now = Date.now();
const row: ConversationRow = {
id: randomUUID(),
user_id: userId,
title: opts.title?.slice(0, 120) || "New chat",
topic: opts.topic?.slice(0, 120) || "",
model: opts.model?.slice(0, 160) || "",
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at);
return row;
}
export function listConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? ORDER BY updated_at DESC").all(userId) as ConversationRow[];
}
export function getConversation(id: string): ConversationRow | null {
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
}
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null {
const d = getDb();
const sets: string[] = ["updated_at = ?"];
const vals: (string | number)[] = [Date.now()];
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); }
if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); }
d.query(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
return getConversation(id);
}
export function touchConversation(id: string, model?: string): void {
if (model) {
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id);
} else {
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);
}
}
export function deleteConversation(id: string): void {
const d = getDb();
const txn = d.transaction(() => {
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
d.query("DELETE FROM conversations WHERE id = ?").run(id);
});
txn();
}
export function addMessage(convId: string, role: string, content: string): void {
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
}
export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
}
// --- password-recovery resets (one-time, hashed, expiring) ---
export interface ResetRow {
token_hash: string;
user_id: string;
created_at: number;
expires_at: number;
}
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
const d = getDb();
// One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
tokenHash, userId, Date.now(), expiresAt,
);
}
export function peekReset(tokenHash: string): ResetRow | null {
const d = getDb();
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
if (!r) return null;
if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return null;
}
return r;
}
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null {
const r = peekReset(tokenHash);
if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
return r;
}
export function deleteUserResets(userId: string): void {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
}
// --- platform settings (admin-controlled feature flags) ---
export function getSetting(key: string, fallback: string): string {
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
return r?.value ?? fallback;
}
export function setSetting(key: string, value: string): void {
getDb().query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
).run(key, value, Date.now());
}
/** Public self-registration switch (default off = admin-created accounts only). */
export function isRegistrationEnabled(): boolean {
return getSetting("registration_enabled", "0") === "1";
}
/** Admin kill-switch for the OCR platform tool (default on). */
export function isOcrToolEnabled(): boolean {
return getSetting("tools_ocr_enabled", "1") === "1";
}
// --- public shares ---
export function ensureShare(convId: string): string {
const d = getDb();
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
if (existing) return existing.public_id;
for (let i = 0; i < 5; i++) {
const publicId = randomBytes(9).toString("base64url");
try {
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now());
return publicId;
} catch {
// collision — retry
}
}
throw new Error("could not generate share id");
}
export function getShareByConv(convId: string): string | null {
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
return r?.public_id ?? null;
}
export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
}
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; sharedAt: number } | null {
const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null;
const conv = getConversation(s.conv_id);
if (!conv) return null;
const author = getUserById(conv.user_id);
return {
conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone",
authorAvatarUrl: author?.avatar_url || "",
sharedAt: s.created_at,
};
}
-124
View File
@@ -1,124 +0,0 @@
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)}`);
}
}
}
}
-71
View File
@@ -1,71 +0,0 @@
import { config } from "../config.ts";
import { ApiDriverBase } from "./apiBase.ts";
/** Mistral API driver. Global priority #2 (after local ollama). */
export class MistralDriver extends ApiDriverBase {
readonly name = "mistral";
constructor() {
super({
baseUrl: "https://api.mistral.ai/v1",
apiKey: config.mistralApiKey,
enabled: config.mistralEnabled,
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() {
super({
baseUrl: "https://api.deepseek.com/v1",
apiKey: config.deepseekApiKey,
enabled: config.deepseekEnabled,
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
});
}
}
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
export class AnthropicDriver extends ApiDriverBase {
readonly name = "anthropic";
constructor() {
super({
baseUrl: "https://api.anthropic.com/v1",
apiKey: config.anthropicApiKey,
enabled: config.anthropicEnabled,
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() {
super({
baseUrl: "https://api.openai.com/v1",
apiKey: config.openaiApiKey,
enabled: config.openaiEnabled,
defaultModels: ["gpt-4o", "gpt-4o-mini"],
});
}
}
/**
* 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"],
});
}
}
-18
View File
@@ -1,18 +0,0 @@
import { config } from "../config.ts";
/** Concise per-driver debug logging to stdout (toggle with DRIVER_DEBUG). */
export function driverLog(driver: string, msg: string): void {
if (!config.driverDebug) return;
const t = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
console.log(`[${t}][driver:${driver}] ${msg}`);
}
export function fmtBytes(n: number): string {
if (n < 1024) return `${n}B`;
return `${(n / 1024).toFixed(1)}KB`;
}
export function fmtMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
-111
View File
@@ -1,111 +0,0 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
interface OllamaTag {
name: string;
}
/** Local LLM via Ollama. Priority #1. */
export class OllamaDriver implements LLMDriver {
readonly name = "ollama";
readonly enabled: boolean;
constructor(public readonly host: string = config.ollamaHost, enabled = config.ollamaEnabled) {
this.host = host.replace(/\/$/, "");
this.enabled = enabled;
}
async listModels(): Promise<DriverModel[]> {
const t0 = Date.now();
const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) {
driverLog(this.name, `listModels ERROR: /api/tags -> ${res.status} (${fmtMs(Date.now() - t0)})`);
throw new Error(`ollama /api/tags failed: ${res.status}`);
}
const data = (await res.json()) as { models?: OllamaTag[] };
const models = data.models ?? [];
driverLog(this.name, `listModels -> ${models.length} model(s) [${models.map((m) => m.name).join(", ")}] (${fmtMs(Date.now() - t0)})`);
return models.map((m) => ({
id: `ollama:${m.name}`,
name: m.name,
driver: "ollama",
label: m.name,
}));
}
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
let out = "";
for await (const t of this.chatStream(messages, opts)) out += t;
return out;
}
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
const t0 = Date.now();
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
const res = await fetch(`${this.host}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: opts.model, messages, stream: true }),
signal: opts.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`);
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let chunks = 0;
let chars = 0;
let status = "done";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean };
if (obj.error) throw new Error(`ollama error: ${obj.error}`);
const token = obj.message?.content ?? "";
if (token) {
chunks++;
chars += token.length;
opts.onToken?.(token);
yield token;
}
if (obj.done) {
driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
return;
}
} catch (e) {
if (e instanceof SyntaxError) continue; // partial line, ignore
throw e;
}
}
}
driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
} catch (e) {
status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`;
throw e;
} finally {
// Stop the upstream generation when the consumer goes away
// (client disconnect) instead of leaking it in the background.
try {
await reader.cancel();
} catch {
// already closed / consumed — fine
}
reader.releaseLock();
if (status !== "done") {
driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
/**
* Puppeteer/headless-browser driver stub (scrapes chatgpt.com).
* Intentionally NOT implemented: fragile, ToS-risky, needs a real
* account session. Kept as a stub so the Driver interface reserves
* its slot at global priority #3.
*/
export class PuppeteerOpenAIDriver implements LLMDriver {
readonly name = "puppeteer-openai";
readonly enabled = false;
async listModels(): Promise<DriverModel[]> {
return [];
}
async chat(_messages: ChatMessage[], _opts: ChatOptions): Promise<string> {
throw new Error(
'driver "puppeteer-openai" is a stub and is not implemented (headless scraping of chatgpt.com is fragile and against ToS; use ollama or official APIs instead)',
);
}
async *chatStream(): AsyncGenerator<string, void, void> {
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
void 0;
}
}
-105
View File
@@ -1,105 +0,0 @@
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
import { OllamaDriver } from "./ollama.ts";
import { PuppeteerOpenAIDriver } from "./puppeteer.ts";
import type { DriverModel, LLMDriver } from "./types.ts";
/**
* Global driver priority (per spec):
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. puppeteer-openai (stub)
* -> 5. deepseek -> 6. anthropic -> 7. openai
*/
const PRIORITY = ["ollama", "mistral", "glm", "puppeteer-openai", "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 PuppeteerOpenAIDriver(),
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 };
}
}
-42
View File
@@ -1,42 +0,0 @@
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;
}
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";
}
}
-1185
View File
File diff suppressed because it is too large Load Diff
-266
View File
@@ -1,266 +0,0 @@
import type { Transporter } from "nodemailer";
import { config } from "./config.ts";
let transporter: Transporter | null = null;
let warned = false;
export function mailEnabled(): boolean {
return config.smtpHost.length > 0;
}
/** Sanitized snapshot for the Admin Center mail viewer (admin-only route). */
export function smtpStatus(): {
enabled: boolean;
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
hasPass: boolean;
from: string;
appUrl: string;
} {
return {
enabled: mailEnabled(),
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
user: config.smtpUser,
pass: config.smtpPass,
hasPass: config.smtpPass.length > 0,
from: config.smtpFrom,
appUrl: config.appUrl,
};
}
/** Verify SMTP connectivity (connect + auth, no mail sent). Throws on failure. */
export async function verifySmtp(): Promise<void> {
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
const mailer = await loadMailer();
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
const t = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
try {
await t.verify();
} finally {
t.close();
}
}
/** Send a connectivity test mail to `to`. Throws on failure, returns messageId. */
export async function sendSmtpTestMail(to: string): Promise<string> {
if (!mailEnabled()) throw new Error("SMTP_HOST not set — mail is disabled");
const mailer = await loadMailer();
if (!mailer) throw new Error("nodemailer not installed — run `bun install` for email support");
const t = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
try {
const info = await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — SMTP test",
text: "This is a KisAssistant SMTP connectivity test. If you received it, outgoing mail works.",
html: `<p>This is a <strong>KisAssistant</strong> SMTP connectivity test. If you received it, outgoing mail works.</p>`,
});
return info.messageId ?? "";
} finally {
t.close();
}
}
// Lazy import: the backend boots (and serves) fine without node_modules
// installed; SMTP is only needed when actually sending.
async function loadMailer(): Promise<typeof import("nodemailer") | null> {
try {
return await import("nodemailer");
} catch {
if (!warned) {
warned = true;
console.error("[mail] nodemailer not installed — run `bun install` for email support");
}
return null;
}
}
async function getTransporter(): Promise<Transporter | null> {
if (!mailEnabled()) return null;
if (!transporter) {
const mailer = await loadMailer();
if (!mailer) return null;
transporter = mailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
auth: config.smtpUser ? { user: config.smtpUser, pass: config.smtpPass } : undefined,
});
}
return transporter;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Shared branded layout for ticket emails. Every mail states the same rule:
* email is notification-only — real replies happen on the /review page.
*/
function ticketLayout(title: string, bodyHtml: string, reviewUrl: string): { html: string; text: string } {
const text = bodyHtml
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "");
const html =
`<!doctype html><html><body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">` +
`<div style="max-width:560px;margin:0 auto;padding:24px 16px;">` +
`<div style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e7e5e4;">` +
`<div style="background:#059669;padding:20px 24px;color:#ffffff;">` +
`<div style="font-size:20px;font-weight:700;">KisAssistant</div>` +
`<div style="font-size:13px;opacity:.85;">${escHtml(title)}</div>` +
`</div>` +
`<div style="padding:24px;color:#1c1917;font-size:14px;line-height:1.6;">${bodyHtml}</div>` +
`<div style="padding:16px 24px;background:#fafaf9;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;">` +
`This email is notification-only — please do not reply to it.<br>` +
`Respond on the official site: <a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a>` +
`</div></div></div></body></html>`;
return { html, text: `${text}\n\n--\nThis email is notification-only, do not reply. Respond at: ${reviewUrl}\n` };
}
async function sendTicketMail(to: string, subject: string, title: string, bodyHtml: string, reviewUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
console.error("[mail] SMTP_HOST not set — ticket email skipped");
return false;
}
try {
const { html, text } = ticketLayout(title, bodyHtml, reviewUrl);
await t.sendMail({ from: config.smtpFrom, to, subject, text, html });
console.log(`[mail] ticket email "${subject}" sent`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
/** Confirm a wishlist request was received, with the /review ticket link. */
export async function sendAccessReceivedEmail(to: string, username: string, reviewUrl: string): Promise<boolean> {
return sendTicketMail(
to,
"KisAssistant — access request received",
"Access request received",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>We received your request for a KisAssistant account. An admin will review it shortly.</p>` +
`<p>Follow the discussion and add arguments here:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>` +
`<p>Keep this link — it is your personal ticket page.</p>`,
reviewUrl,
);
}
/** Notify status changes. `key` is set only on acceptance (shown once). */
export async function sendAccessStatusEmail(
to: string,
username: string,
status: "reviewing" | "accepted" | "refused",
reason: string,
reviewUrl: string,
key?: string,
): Promise<boolean> {
if (status === "accepted") {
return sendTicketMail(
to,
"KisAssistant — access granted",
"Access granted",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.</p>` +
`<p>Your access key (shown <strong>once</strong> — save it now):</p>` +
`<p style="background:#f5f5f4;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:15px;">${escHtml(key ?? "")}</p>` +
`<p>Log in with your username and this key. You can still discuss on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
if (status === "refused") {
return sendTicketMail(
to,
"KisAssistant — access request refused",
"Access refused",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Your request for a KisAssistant account was refused${reason ? ` for this reason: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You may still reply on your ticket page if you think this is a mistake:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
return sendTicketMail(
to,
"KisAssistant — access request under review",
"Under review",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>An admin is now reviewing your request${reason ? `: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You can add arguments on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Forward each new ticket message to the requester (notification-only). */
export async function sendAccessMessageEmail(
to: string,
username: string,
fromAdmin: boolean,
body: string,
reviewUrl: string,
): Promise<boolean> {
const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body;
return sendTicketMail(
to,
`KisAssistant — new message on your access request`,
"New ticket message",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p><strong>${fromAdmin ? "An admin" : "You"}</strong> wrote on your access-request ticket:</p>` +
`<p style="background:#f5f5f4;border-left:4px solid #059669;border-radius:0 8px 8px 0;padding:12px 16px;white-space:pre-wrap;">${escHtml(excerpt)}</p>` +
`<p>Reply on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
if (!warned) {
warned = true;
console.error("[mail] SMTP_HOST not set — recovery emails disabled");
}
return false;
}
try {
await t.sendMail({
from: config.smtpFrom,
to,
subject: "KisAssistant — recover your access key",
text:
`Hello ${username},\n\n` +
`Someone requested a new access key for your KisAssistant account.\n` +
`Open this link within 60 minutes to get a fresh key (your old one stops working):\n\n` +
`${resetUrl}\n\n` +
`If you did not request this, just ignore this email.\n`,
});
console.log(`[mail] recovery email sent to ${username}`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
-38
View File
@@ -1,38 +0,0 @@
import { join } from "node:path";
const PUBLIC_DIR = join(import.meta.dir, "..", "public");
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
};
export async function serveStatic(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("not found", { status: 404 });
}
let path = decodeURIComponent(url.pathname);
if (path === "/") path = "/index.html";
// block traversal
if (path.includes("..") || path.includes("\0")) return new Response("not found", { status: 404 });
const file = Bun.file(join(PUBLIC_DIR, path));
if (!(await file.exists())) {
// SPA fallback for non-API routes
if (!path.startsWith("/api/")) {
return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
}
return new Response("not found", { status: 404 });
}
const ext = path.slice(path.lastIndexOf("."));
return new Response(file, {
headers: {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": path === "/index.html" ? "no-cache" : "public, max-age=3600",
},
});
}
-121
View File
@@ -1,121 +0,0 @@
import { config } from "../config.ts";
import { isOcrToolEnabled } from "../db.ts";
import { driverLog, fmtMs } from "../drivers/log.ts";
import type { PlatformTool } from "./types.ts";
/**
* OCR tool: transcribes JPG/PNG/WEBP images to TXT on the backend
* (tesseract binary) so the model only ever receives text.
* The frontend shows the transcription for user review before sending.
*/
const OCR_MAX_BYTES = 5 * 1024 * 1024;
// CPU protection: max OCR jobs per account per hour.
const OCR_WINDOW_MS = 3600_000;
const OCR_MAX_JOBS = 20;
const ocrJobs = new Map<string, number[]>();
export function ocrAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
const now = Date.now();
const list = (ocrJobs.get(userId) ?? []).filter((t) => t > now - OCR_WINDOW_MS);
ocrJobs.set(userId, list);
if (list.length >= OCR_MAX_JOBS) {
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + OCR_WINDOW_MS - now) / 1000)) };
}
return { ok: true, retryAfterSec: 0 };
}
export function recordOcrJob(userId: string): void {
const list = ocrJobs.get(userId) ?? [];
list.push(Date.now());
ocrJobs.set(userId, list);
}
function tessBin(): string {
return process.env["TESSERACT_BIN"] || "tesseract";
}
let availableCache: boolean | null = null;
async function checkBinary(): Promise<boolean> {
try {
const proc = Bun.spawn([tessBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
const code = await proc.exited;
return code === 0;
} catch {
return false;
}
}
export class OcrTool implements PlatformTool {
readonly name = "ocr";
readonly description = "Transcribe image (jpg/png/webp) to text on the server (tesseract) for user review before sending to the model.";
private available: boolean | null = null;
async init(): Promise<void> {
if (availableCache === null) {
availableCache = await checkBinary();
driverLog("tools", `ocr engine ${tessBin()}: ${availableCache ? "available" : "MISSING — image transcription disabled"}`);
}
this.available = availableCache;
}
isAvailable(): boolean {
return (this.available ?? false) && isOcrToolEnabled();
}
unavailableReason(): string | null {
if (!(this.available ?? false)) {
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
}
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features).";
return null;
}
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
try {
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
stdin: image,
stdout: "pipe",
stderr: "pipe",
});
} catch (e) {
throw new Error(`ocr engine failed to start: ${(e as Error).message}`);
}
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
// already exited
}
}, 120_000);
try {
const [code, out, errText] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if (code !== 0) {
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
}
let text = out.replace(/\f/g, "").trim();
let truncated = false;
if (text.length > maxChars) {
text = text.slice(0, maxChars);
truncated = true;
}
driverLog("tools", `ocr done: ${text.length} chars${truncated ? " (truncated)" : ""} in ${fmtMs(Date.now() - t0)}`);
return { text, truncated };
} finally {
clearTimeout(timeout);
}
}
}
export { OCR_MAX_BYTES };
-23
View File
@@ -1,23 +0,0 @@
import { OcrTool } from "./ocr.ts";
import type { PlatformTool } from "./types.ts";
/** Platform tools registry (upload flows always go through tools). */
class ToolRegistry {
readonly ocr = new OcrTool();
private tools: PlatformTool[] = [this.ocr];
async init(): Promise<void> {
await this.ocr.init();
}
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
return this.tools.map((t) => ({
name: t.name,
description: t.description,
available: t.isAvailable(),
reason: t.unavailableReason(),
}));
}
}
export const tools = new ToolRegistry();
-8
View File
@@ -1,8 +0,0 @@
/** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */
export interface PlatformTool {
readonly name: string;
readonly description: string;
isAvailable(): boolean;
unavailableReason(): string | null;
}
-139
View File
@@ -1,139 +0,0 @@
import { createHash, createHmac, randomBytes } from "node:crypto";
import { getDb } from "./db.ts";
/**
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
* The per-user secret lives in `users.totp_secret` (empty = disabled);
* login challenges live in `totp_challenges`. Tables/columns are created
* idempotently here so db.ts stays untouched.
*/
const STEP_SEC = 30;
const DIGITS = 6;
const WINDOW = 1;
const CHALLENGE_TTL_MS = 5 * 60_000;
export function initTotpTables(): void {
const d = getDb();
try {
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
} catch {
// column already exists — fine
}
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`).run();
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function newTotpSecret(bytes = 20): string {
const buf = randomBytes(bytes);
let out = "";
let bits = 0;
let value = 0;
for (const b of buf) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
function base32Decode(s: string): Buffer {
const clean = s.trim().replace(/=+$/, "").toUpperCase();
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error("invalid base32");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
function hotp(key: Buffer, counter: bigint): string {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(counter);
const h = createHmac("sha1", key).update(msg).digest();
const offset = h[h.length - 1]! & 0x0f;
const code =
((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!));
return String(code % 10 ** DIGITS).padStart(DIGITS, "0");
}
/** Timing-safe comparison against the current ±1 window. */
export function verifyTotp(secret: string, code: string): boolean {
const c = code.trim();
if (!/^\d{6}$/.test(c)) return false;
let key: Buffer;
try {
key = base32Decode(secret);
} catch {
return false;
}
if (key.length < 10) return false;
const now = Math.floor(Date.now() / 1000 / STEP_SEC);
let ok = false;
for (let w = -WINDOW; w <= WINDOW; w++) {
const expected = hotp(key, BigInt(now + w));
const a = Buffer.from(expected);
const b = Buffer.from(c);
if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) {
ok = true;
}
}
// Constant-time-ish: always hash-compare all window slots (loop above).
return ok;
}
export function getTotpSecret(userId: string): string {
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
return row?.s ?? "";
}
export function setTotpSecret(userId: string, secret: string): void {
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
}
/** Short-lived login challenge after a correct key; returns the raw token. */
export function createTotpChallenge(userId: string): string {
const token = "tc_" + randomBytes(24).toString("base64url");
const hash = createHash("sha256").update(token).digest("hex");
const now = Date.now();
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
hash, userId, now, now + CHALLENGE_TTL_MS,
);
// Opportunistic cleanup.
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
return token;
}
export function consumeTotpChallenge(token: string): string | null {
const hash = createHash("sha256").update(token).digest("hex");
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
user_id: string;
expires_at: number;
} | null;
if (!row) return null;
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
if (row.expires_at < Date.now()) return null;
return row.user_id;
}
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"types": ["bun"]
},
"include": ["src"]
}