Compare commits

..
6 Commits
Author SHA1 Message Date
k 0e44bf6364 fixes 2026-09-21 11:47:59 +02:00
k db14c9bf21 . 2026-09-21 11:41:51 +02:00
k d05dad2e48 wiki update 2026-09-21 11:38:19 +02:00
k 56628fa103 Update ui.tsx 2026-09-21 11:29:05 +02:00
k d37f4efd69 lil update bro 2026-09-21 11:24:16 +02:00
k 739c14324a KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module 2026-09-21 11:00:32 +02:00
50 changed files with 8572 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# --- server ---
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
# 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
# --- rate limit on /api/auth/login (per IP, sliding window) ---
RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MIN=10
# --- drivers ---
# Priority (global): ollama -> mistral -> 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
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
+10
View File
@@ -0,0 +1,10 @@
data/
node_modules/
dist/
public/
.env
*.log
*.db
*.db-journal
*.db-wal
*.db-shm
+54
View File
@@ -0,0 +1,54 @@
# AGENTS.md — instructions for coding agents working on KisAssistant
Read this before touching the code. Docs live in `docs/` (English).
## What this is
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
frontend, SQLite persistence via Bun's native `bun:sqlite` driver.
## Commands (run from repo root unless noted)
- `bun install` — backend deps (almost none; `@types/bun` only)
- `bun run dev` — backend with **watch mode** (auto-restart on edit), http://localhost:3000
- `bun run dev:frontend` — Vite dev server with HMR + `/api` proxy, http://localhost:5173
- `bun run dev:all` — both at once (two terminals also works)
- `bun run build:frontend` / `bun --cwd frontend build` — typecheck + Vite build into `public/` (wipes it first)
- `bun run typecheck` / `bun run typecheck:frontend` — `tsc --noEmit` for each side
- API smoke tests: boot on a **non-3000 port** with throwaway `DATA_DIR`, drive with curl/python (see `docs/OPERATIONS.md`). Port 3000 is the owner's dev server — **never kill it, never bind it in tests**.
## Layout
```
src/index.ts HTTP routes (Bun.serve). Auth gate: everything under /api/ except /api/auth/login and /api/share/:id
src/config.ts env parsing (PORT, HOST, APP_PASSWORD=admin key, OLLAMA_HOST, DATA_DIR, rate limits)
src/db.ts SQLite store: users, sessions, conversations, messages, shares (+ admin row bootstrap)
src/auth.ts login(username,key), hashed Bearer sessions, sliding-window login rate limit
src/static.ts static file server + SPA fallback (covers /share/:id)
src/drivers/ LLMDriver interface, OllamaDriver, ApiDriverBase, Mistral/DeepSeek/Anthropic/OpenAI, 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.
+120
View File
@@ -0,0 +1,120 @@
# 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. puppeteer-openai (stub, not implemented)
→ 4. deepseek → 5. anthropic → 6. 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).
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 URL (host images e.g. on `catbox.moe` and
paste the link; empty = initials avatar), theme (`auto`/`light`/`dark`).
## API
| Method | Route | Auth | Notes |
|---|---|---|---|
| POST | `/api/auth/login` | rate-limited (5/10min/IP) | `{username, key}` → `{token, expiresAt, user}` |
| 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/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
| 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, key_hash), `sessions` (hashed Bearer tokens),
`conversations` (id, user, title, topic, model), `messages`, `shares`
(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)
Flake exposes `packages.<system>.default` (backend + prebuilt frontend) and
`nixosModules.default`. The frontend is built offline via `buildNpmPackage`
(`frontend/package-lock.json`); on first build, replace the placeholder
`npmDepsHash` with the hash Nix suggests.
```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;
domain = "chat.kisakay.com"; # nginx reverse proxy + ACME
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434";
};
```
Nginx terminates TLS; the app listens on `127.0.0.1:3000`. SQLite lives in
`/var/lib/kisassistant/data`.
## 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.
+6
View File
@@ -0,0 +1,6 @@
--- HIGH PRIORITY
Being abble to :
Disable features / Enable it:
ie : thinking, deep search, etc...
Downloading models throught a catalog : ollama only
+34
View File
@@ -0,0 +1,34 @@
--- 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,
+21
View File
@@ -0,0 +1,21 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant",
"devDependencies": {
"@types/bun": "latest",
},
},
},
"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=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
}
}
+80
View File
@@ -0,0 +1,80 @@
# 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, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
| `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. |
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
User deletion cascades manually in `deleteUser()` (messages → shares →
conversations → sessions → user) inside a transaction.
## Auth
- `POST /api/auth/login { username, key }`, sliding-window rate limit per IP
(`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN`, default 5 per 10 min) + 400 ms
delay on failure. Success clears the IP counter and issues a fresh token.
- `admin` is authenticated against `APP_PASSWORD` (timing-safe); users against
their `ka_…` access key (timing-safe against the stored hash).
- Keys are generated with `crypto.randomBytes` (`ka_` + 24 bytes base64url) and
**returned once** at creation/regeneration. Regeneration also wipes the
account's sessions.
## Drivers
`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
`DriverRegistry` with global priority
`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`.
Models are addressed as `"driver:model"` (split on the first `:`).
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
`POST /api/chat` with NDJSON streaming.
- `ApiDriverBase`: shared OpenAI-compatible `/models` + `/chat/completions`
(SSE) implementation; concrete drivers only set base URL, key, and fallback
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
its key is non-empty; failing drivers are skipped (not fatal) in listings.
- `PuppeteerOpenAIDriver`: intentional stub, always disabled (headless scraping
of chatgpt.com is fragile and against ToS).
+54
View File
@@ -0,0 +1,54 @@
# Frontend
React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/remark-gfm, Inter
Variable self-hosted (`@fontsource-variable/inter` — no external requests).
Built with `bun run build:frontend` into `public/` (Vite `emptyOutDir`; that
directory is a build artifact, never hand-edit, not committed).
## Routing
No router library — `App.tsx` matches `window.location.pathname` once (module
level, constant for the page lifetime): `/share/:publicId` renders the public
read-only `SharePage` (no auth), everything else renders the authenticated app.
## State & data flow
- `App.tsx` owns: `user`, `models`/`model`, `convs`, `activeId`, `messages`,
`streaming`, modal states. All server state is re-fetched (no client cache
library); conversations themselves live server-side in SQLite.
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
- Sending: ensure a server conversation exists (created lazily on first send),
optimistic user message, stream tokens into `streaming`, then append the full
assistant message and refresh the list (server auto-titles new chats).
## Components (`components/`)
- `ui.tsx` — design system. `Button` (primary/secondary/ghost/danger),
`IconButton`, `Input`, `Field`, `Modal`, `ConfirmDialog`, `ContextMenu`,
`Avatar` (remote URL or gradient initials), `CopyButton`, `Spinner` and
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
**Never use a native `<select>`**; use `Picker` for model and theme choices.
- `Login.tsx` — username + access-key card.
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
(right-click or `···`: Rename / Set topic / Share publicly / Delete), user
chip (opens settings), Accounts button (admin), logout.
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
share button), message list (`Markdown` for assistant, emerald bubble for
user), rounded composer (Enter to send, Shift+Enter for newline).
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
copy, unshare), `SettingsModal` (display name, avatar URL, theme).
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
regenerate (revokes sessions), edit, delete with confirm.
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated.
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
a copy button.
## Theming
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
class from the account theme (`auto` follows `prefers-color-scheme` with a
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
everywhere; every surface needs a `dark:` variant. UI text is English only.
+66
View File
@@ -0,0 +1,66 @@
# Operations
## Environment
Copy `.env.example` to `.env`. Full list:
| Variable | Default | Purpose |
|---|---|---|
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
| `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there |
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
| `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
## Local development
```bash
bun install
bun run dev # backend watch mode (restarts on edit), :3000
bun run dev:frontend # Vite HMR + /api proxy, :5173 (use this for UI work)
bun run dev:all # both at once
bun run build:frontend # production build into public/
```
Smoke tests: start the backend on a **free non-3000 port** with a throwaway
`DATA_DIR` (port 3000 is the owner's dev server — do not touch it), then drive
the API with curl/python: login → models → conversations → chat → share →
unshare. See `AGENTS.md`.
## Managing accounts
1. Log in as `admin` with the `APP_PASSWORD` key.
2. Open **Accounts** → *New account* → username (+ display name).
3. Copy the one-time `ka_…` key and hand it to the owner (chat, QR code, …).
4. Users set their display name, avatar URL (upload e.g. to `catbox.moe`, paste
the direct link) and theme in the profile settings.
5. Rotate a compromised key with the regenerate button (old sessions die);
delete removes the account with all its chats and shares.
## NixOS deployment (`chat.kisakay.com`)
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend)
and `nixosModules.default`. The frontend is compiled offline with
`buildNpmPackage` from `frontend/package-lock.json` — commit that file, and on
a hash mismatch paste the hash Nix reports into `npmDepsHash`.
```nix
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
package = kisassistant.packages.${pkgs.system}.default;
domain = "chat.kisakay.com"; # nginx + ACME
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
ollamaHost = "http://10.66.66.4:11434";
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
# secrets for API drivers: pass via environment files / extraEnv, never the store
};
```
Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never
baked into the Nix store. The service stores SQLite under
`/var/lib/kisassistant/data`; nginx terminates TLS and proxies to
`127.0.0.1:3000`.
+50
View File
@@ -0,0 +1,50 @@
{
description = "KisAssistant — private chat assistant (Bun backend + React frontend, SQLite)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }: {
nixosModules.default = import ./module.nix;
packages.x86_64-linux.default =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
# Frontend: built offline from the npm lockfile. If the build fails
# with a hash mismatch, replace npmDepsHash with the suggested one.
frontend = pkgs.buildNpmPackage {
pname = "kisassistant-frontend";
version = "0.2.0";
src = ./frontend;
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
npmBuildScript = "build";
installPhase = ''
mkdir -p $out
cp -r dist/* $out/
'';
};
in pkgs.stdenv.mkDerivation {
pname = "kisassistant";
version = "0.2.0";
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
@@ -0,0 +1,598 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "kisassistant-frontend",
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/inter": ["@fontsource-variable/[email protected]", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/[email protected]", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="],
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="],
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["[email protected]", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-jsx-runtime": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-url-attributes": ["[email protected]", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"inline-style-parser": ["[email protected]", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["[email protected]", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-decimal": ["[email protected]", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["[email protected]", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["[email protected]", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["[email protected]", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["[email protected]", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="],
"micromark-extension-gfm-tagfilter": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["[email protected]", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["[email protected]", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["[email protected]", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"node-releases": ["[email protected]", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="],
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"parse-entities": ["[email protected]", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-markdown": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"read-cache": ["[email protected]", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"remark-gfm": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"style-to-js": ["[email protected]", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["[email protected]", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["[email protected]", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unified": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KisAssistant</title>
<meta name="description" content="KisAssistant — minimal private chat assistant" />
<link rel="icon" type="image/jpeg" href="/logo.jpg" />
<meta name="theme-color" content="#059669" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3833
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "kisassistant-frontend",
"version": "0.2.0",
"description": "KisAssistant React frontend (Vite + Tailwind + lucide-react)",
"author": "kisakay",
"license": "MIT",
"repository": {
"type": "git",
"url": "http://git.kisakay.com/k/chat",
"directory": "frontend"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+245
View File
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts";
import type { ChatMessage, Conversation, DriverModel, User } from "./lib/types.ts";
import { Login } from "./components/Login.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, SettingsModal, ShareModal } from "./components/dialogs.tsx";
import { AdminPanel } from "./components/AdminPanel.tsx";
import { SharePage } from "./components/SharePage.tsx";
import { ConfirmDialog } from "./components/ui.tsx";
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;
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 && !!getToken());
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 [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 [adminOpen, setAdminOpen] = useState(false);
// Public share route: no auth needed. SHARE_ID is constant for the page lifetime.
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
useEffect(() => {
if (!getToken()) {
setChecking(false);
return;
}
api.verify()
.then(async (res) => {
setUser(res.user);
applyTheme(res.user.theme);
const [m, c] = await Promise.all([api.models().catch(() => ({ models: [] })), api.convs().catch(() => ({ conversations: [] }))]);
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(() => setChecking(false));
}, []);
// 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(() => {});
refreshConvs();
}
function logout() {
api.logout().finally(() => {});
clearToken();
setUser(null);
setConvs([]);
setMessages([]);
setActiveId(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 };
const history = [...messages, userMsg];
setMessages(history);
setSending(true);
setStreaming("");
let full = "";
try {
await api.chatStream({ model, messages: history, conversationId: convId! }, (t) => {
full += t;
setStreaming(full);
});
setMessages([...history, { role: "assistant", content: full }]);
setStreaming("");
await refreshConvs();
} catch {
setMessages([...history, { role: "assistant", content: "**Error:** the model did not respond. Try again." }]);
setStreaming("");
} finally {
setSending(false);
}
}
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 <div className="grid min-h-full place-items-center text-sm opacity-60">Loading…</div>;
}
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}
onSelect={setActiveId}
onRename={setEditConv}
onTopic={setEditConv}
onShare={setShareConv}
onDelete={setDeleteConv}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAdmin={() => setAdminOpen(true)}
onLogout={logout}
/>
<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)}
/>
<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="Delete conversation?"
message={`"${deleteConv?.title}" and all its messages will be permanently removed.`}
onConfirm={() => deleteConv && removeConv(deleteConv)}
/>
<SettingsModal
user={settingsOpen ? user : null}
onClose={() => setSettingsOpen(false)}
onSaved={(u) => {
setUser(u);
applyTheme(u.theme);
}}
/>
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} />
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import { KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { User } from "../lib/types.ts";
import { Avatar, Button, ConfirmDialog, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
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);
// create form
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
async function refresh() {
setBusy(true);
setError("");
try {
const res = await api.adminList();
setUsers(res.users);
} catch (err) {
setError(err instanceof Error ? err.message : "Load failed");
} finally {
setBusy(false);
}
}
useEffect(() => {
if (open) {
refresh();
setShowCreate(false);
setFreshKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open ]);
async function create(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined });
setFreshKey({ username: res.user.username, key: res.key });
setUsername("");
setDisplayName("");
setShowCreate(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Create failed");
}
}
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 : "Regenerate failed");
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
}
}
return (
<>
<Modal open={open} onClose={onClose} title="Accounts" icon={Users} wide>
<p className="mb-4 text-sm opacity-70">
No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.
</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-emerald-500/40 bg-emerald-50 p-4 dark:bg-emerald-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> Key for @{freshKey.username} — 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.key}</code>
<CopyButton text={freshKey.key} />
</div>
</div>
)}
{!showCreate ? (
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)} className="mb-3">
<UserPlus size={15} /> New account
</Button>
) : (
<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="Username"><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
</div>
<div className="min-w-40 flex-1">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
</div>
<Button size="sm" type="submit">Create</Button>
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
</form>
)}
{busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> 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="text-xs opacity-50">theme: {u.theme} · since {new Date(u.createdAt).toLocaleDateString()}</p>
</div>
{!u.isAdmin && (
<div className="flex shrink-0 gap-1">
<button title="Edit profile" onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title="Regenerate key (revokes old sessions)" onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title="Delete account" 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>
)}
</Modal>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={`Delete @${deleteTarget?.username}?`}
message="This permanently removes the account, all its conversations and its shares."
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
</>
);
}
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [theme, setTheme] = useState("auto");
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
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(), theme });
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
}
}
return (
<Modal open={user !== null} onClose={onClose} title={`Edit @${user?.username}`} icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
<Field label="Theme">
<Picker
ariaLabel="Theme"
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "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}>Cancel</Button>
<Button size="sm" type="submit">Save</Button>
</div>
</form>
</Modal>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, User as UserIcon } from "lucide-react";
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { Avatar, IconButton, LOGO_URL, Logo, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts";
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
<Avatar name={authorName} url={isUser ? authorAvatar : LOGO_URL} 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 ? "You" : "KisAssistant"}
</div>
{isUser ? (
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-emerald-600 px-4 py-2.5 text-[15px] text-white dark:bg-emerald-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,
}: {
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;
}) {
const [draft, setDraft] = useState("");
const boxRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(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">
{sidebarCollapsed && (
<IconButton title="Expand sidebar" onClick={onExpandSidebar}>
<PanelLeftOpen size={18} />
</IconButton>
)}
<span className="hidden items-center gap-2 font-semibold sm:flex">
<Logo size={24} className="rounded-lg" />
{conv?.title || "New chat"}
</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="Model"
icon={Cpu}
value={model}
onChange={onModelChange}
groups={modelGroups}
placeholder={models.length === 0 ? "No models" : "Select model…"}
disabled={models.length === 0}
/>
{conv && (
<IconButton title="Share publicly" 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">
<Logo size={56} className="mx-auto mb-4 rounded-[1.3rem] shadow-lg shadow-red-900/10" />
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</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">
<Avatar name="KisAssistant" url={LOGO_URL} 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">Thinking…</span></div>
)}
</div>
</div>
<div className="px-4 pb-2 sm:px-8">
<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 pl-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<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="Message KisAssistant…"
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() || sending}
aria-label="Send"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-emerald-600 text-white shadow transition hover:bg-emerald-500 active:scale-95 disabled:opacity-40 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-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} />
Wiki & docs
</a>
</p>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from "react";
import { ArrowRight, KeyRound, ShieldCheck, User, Zap } from "lucide-react";
import { api, setToken } from "../lib/api.ts";
import type { User as UserType } from "../lib/types.ts";
import { Button, Input, Logo, Spinner } from "./ui.tsx";
const PERKS = [
{ icon: ShieldCheck, text: "Private by design — no cookies, no tracking" },
{ icon: Zap, text: "Your own models, streamed in real time" },
];
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [username, setUsername] = useState("admin");
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setError("");
setBusy(true);
try {
const res = await api.login(username.trim().toLowerCase(), key);
setToken(res.token);
onLogin(res.user);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} 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-emerald-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-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-emerald-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">
<Logo size={68} className="mb-4 rounded-[1.5rem] shadow-lg shadow-red-900/20" />
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p>
</div>
<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="Username"
aria-label="Username"
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="Access key"
aria-label="Access key"
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 /> : (<>Unlock <ArrowRight size={17} /></>)}
</Button>
</form>
<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-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<p.icon size={14} />
</span>
{p.text}
</li>
))}
</ul>
</div>
<p className="mt-4 text-center text-xs opacity-50">
Admin key lives in the server <code>.env</code>; user keys are issued by the admin.
</p>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
return (
<div className="relative">
{lang && (
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
)}
<div className="absolute right-2.5 top-2">
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
</pre>
</div>
);
}
export function Markdown({ text }: { text: string }) {
return (
<div className="md space-y-3 text-[15px] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre({ children }) {
// react-markdown nests <code> inside <pre>; render our block instead.
const child = Array.isArray(children) ? children[0] : children;
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
const className = props?.className || "";
const lang = className.replace("language-", "");
const code = String(props?.children ?? "").replace(/\n$/, "");
return <CodeBlock code={code} lang={lang} />;
},
code({ children }) {
return <code>{children}</code>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Bot, Globe, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts";
import { Avatar, LOGO_URL, Logo } 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">
<Logo size={36} className="rounded-2xl" />
<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-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 dark:bg-emerald-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 && <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
{!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">
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} url={m.role === "user" ? undefined : LOGO_URL} 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>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import {
MessageSquarePlus,
MoreVertical,
PanelLeftClose,
Pencil,
Settings,
Share2,
Tag,
Trash2,
Users,
} from "lucide-react";
import type { Conversation, User } from "../lib/types.ts";
import { Avatar, Button, ContextMenu, 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,
}: {
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;
}) {
const [menu, setMenu] = useState<MenuState | null>(null);
function openMenu(e: React.MouseEvent, conv: Conversation) {
e.preventDefault();
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY, conv });
}
if (collapsed) return null;
return (
<aside className="flex h-full w-72 shrink-0 flex-col gap-2 border-r border-stone-200/70 bg-white/70 p-3 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="flex items-center gap-1">
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
<MessageSquarePlus size={16} />
New chat
</Button>
<IconButton title="Collapse sidebar" onClick={onToggle}>
<PanelLeftClose size={18} />
</IconButton>
</div>
<div className="flex-1 space-y-1 overflow-y-auto py-1">
{convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
)}
{convs.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => onSelect(c.id)}
onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}
onContextMenu={(e) => openMenu(e, c)}
className={cn(
"group flex cursor-pointer items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition",
c.id === activeId
? "bg-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-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="Conversation options"
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: "Rename", onClick: () => onRename(menu.conv) },
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
]}
/>
)}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800">
<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 ? " · 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="Manage accounts">
<Users size={15} />
Accounts
</Button>
)}
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
Log out
</Button>
</div>
</div>
</aside>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import { Globe, Link2, Pencil, Settings2, Tag, Unplug } from "lucide-react";
import { api } from "../lib/api.ts";
import type { Conversation, User } from "../lib/types.ts";
import { Avatar, Button, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx";
/* ---------- Rename + topic ---------- */
export function ConvEditDialog({
conv,
onClose,
onSaved,
}: {
conv: Conversation | null;
onClose: () => void;
onSaved: (c: Conversation) => void;
}) {
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 : "Save failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={conv !== null} onClose={onClose} title="Edit conversation" icon={Pencil}>
<form onSubmit={save} className="space-y-4">
<Field label="Title">
<Input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} required />
</Field>
<Field label="Topic" hint="A short theme tag, e.g. cooking, rust, travel.">
<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="No topic" />
</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}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
</div>
</form>
</Modal>
);
}
/* ---------- Share ---------- */
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
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 : "Share failed"))
.finally(() => setBusy(false));
}, [conv]);
async function unshare() {
if (!conv) return;
await api.unshare(conv.id);
onClose();
}
return (
<Modal open={conv !== null} onClose={onClose} title="Share publicly" icon={Globe}>
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> Creating public link…</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">Anyone with this link can read <strong>{conv?.title}</strong>. No login required.</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} /> Unshare</Button>
<Button size="sm" onClick={onClose}>Done</Button>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Settings (profile) ---------- */
export function SettingsModal({
user,
onClose,
onSaved,
}: {
user: User | null;
onClose: () => void;
onSaved: (u: User) => void;
}) {
const [displayName, setDisplayName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [theme, setTheme] = useState("auto");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.displayName);
setAvatarUrl(user.avatarUrl);
setTheme(user.theme);
setError("");
}
}, [user]);
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(), theme });
onSaved(res.user);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={user !== null} onClose={onClose} title="Profile & appearance" icon={Settings2}>
<form onSubmit={save} className="space-y-4">
<div className="flex items-center gap-3">
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
<p className="text-sm opacity-60">@{user?.username}</p>
</div>
<Field label="Display name">
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
</Field>
<Field label="Avatar URL" hint="Host an image on catbox.moe (or anywhere) and paste the direct link here. Empty = initials.">
<Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" inputMode="url" />
</Field>
<Field label="Theme">
<Picker
ariaLabel="Theme"
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: "Auto (follows system)" },
{ value: "light", label: "Light" },
{ value: "dark", label: "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}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
</div>
</form>
</Modal>
);
}
+436
View File
@@ -0,0 +1,436 @@
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";
/* ---------- Button ---------- */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "gradient" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg" | "icon";
};
export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
return (
<button
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-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-zinc-950",
variant === "gradient" && "bg-gradient-to-r from-emerald-600 to-teal-500 text-white shadow-lg shadow-emerald-600/25 hover:from-emerald-500 hover:to-teal-400 dark:from-emerald-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-emerald-500 focus:ring-2 focus:ring-emerald-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-emerald-500/60 focus:bg-white focus:ring-4 focus:ring-emerald-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 = "Select…",
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 [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);
// 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-emerald-500/60 hover:shadow disabled:cursor-not-allowed disabled:opacity-50",
"dark:border-zinc-700 dark:bg-zinc-900",
open && "border-emerald-500 ring-2 ring-emerald-500/20",
)}
>
{Icon && <Icon size={15} className="shrink-0 text-emerald-600 dark:text-emerald-400" />}
<span className="truncate">{selected?.label ?? placeholder}</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-emerald-600/10 font-medium text-emerald-900 dark:bg-emerald-500/10 dark:text-emerald-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-emerald-600 dark:text-emerald-400" />}
</button>
);
})}
</div>
))}
</div>
</>,
document.body,
)}
</div>
);
}
/* ---------- 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]);
if (!open) return null;
const Icon = icon;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center 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-[85vh] w-full overflow-y-auto rounded-3xl border border-stone-200 bg-white p-6 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900",
wide ? "max-w-2xl" : "max-w-md",
)}
>
<div className="mb-4 flex items-center gap-2.5">
{Icon && (
<span className="grid h-9 w-9 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<Icon size={18} />
</span>
)}
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} aria-label="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>
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = "Delete",
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
}) {
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}>Cancel</Button>
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel}</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]);
return (
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }}>
<div
ref={ref}
className="absolute w-52 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl 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>
);
}
/* ---------- Avatar ---------- */
const GRADIENTS = [
"from-emerald-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 }) {
if (url) {
return <img src={url} 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 ---------- */
export const LOGO_URL = "/logo.jpg";
export function Logo({ size = 36, className }: { size?: number; className?: string }) {
return (
<img
src={LOGO_URL}
alt="KisAssistant logo"
width={size}
height={size}
className={cn("shrink-0 object-cover", className)}
style={{ width: size, height: size }}
/>
);
}
/* ---------- Misc ---------- */
export function Spinner({ size = 18 }: { size?: number }) {
return <Loader2 size={size} className="animate-spin" />;
}
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<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-emerald-500" /> : <Copy size={13} />}
{done ? "Copied" : label}
</button>
);
}
+84
View File
@@ -0,0 +1,84 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
}
body {
@apply bg-stone-100 text-stone-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
}
/* 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-emerald-600 underline dark:text-emerald-400;
}
.md ul { @apply list-disc pl-5; }
.md ol { @apply list-decimal pl-5; }
.md blockquote {
@apply border-l-4 border-emerald-500/50 pl-3 italic opacity-80;
}
.typing-cursor::after {
content: "▍";
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* 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;
}
+120
View File
@@ -0,0 +1,120 @@
import type { ChatMessage, Conversation, DriverModel, SharedChat, User } from "./types.ts";
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 }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, key }),
}, false),
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
me: () => req<{ user: User }>("/api/me"),
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
adminList: () => req<{ users: User[] }>("/api/admin/users"),
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: 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 }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
convs: () => req<{ conversations: Conversation[] }>("/api/conversations"),
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: () => req<{ models: DriverModel[] }>("/api/models"),
/** Streaming chat. Calls onToken per token; resolves with the full text. */
chatStream: async (
args: { model: string; messages: ChatMessage[]; conversationId?: string },
onToken: (t: string) => void,
): Promise<string> => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ ...args, stream: true }),
});
if (res.status === 401) {
clearToken();
window.location.reload();
throw new ApiError(401, "Session expired.");
}
if (!res.ok || !res.body) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, err.error || `Chat failed (${res.status})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let full = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop() ?? "";
for (const ev of events) {
const m = ev.match(/^event: (\w+)\ndata: ([\s\S]*)$/m);
if (!m) continue;
const [, event, data] = m as [string, string, string];
if (event === "token") {
const token = (JSON.parse(data) as { token: string }).token;
full += token;
onToken(token);
} else if (event === "error") {
throw new ApiError(502, (JSON.parse(data) as { error?: string }).error || "stream error");
}
}
}
return full;
},
};
+3
View File
@@ -0,0 +1,3 @@
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
+40
View File
@@ -0,0 +1,40 @@
export interface User {
id: string;
username: string;
displayName: string;
avatarUrl: string;
theme: 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 SharedChat {
title: string;
topic: string;
model: string;
authorName: string;
sharedAt: number;
messages: ChatMessage[];
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";
import { App } from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+13
View File
@@ -0,0 +1,13 @@
/** @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"],
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,22 @@
Changement frontend à faire:
faire du joli front-end css rounded comme un peu signal:
police système override par une font belle
les systèmes de Chat à gauche: Collapsable , joli contextual menu pour delete chat / share it pubicly
(tu vas faire une page /share que les chat système partager pourront être vu publiquement)
Des îcones joli avec lucide.dev
Tu peux revoir le frotnend pour faire un framework joli.
Les bouttons native HTML5/CSS/JS:
Vraiment moche, les boutons bleu sont pas ebau, utilise un framework HTML telle que
- Svelte avec SHADCDN
- ou bien React Lucide avec tailwindCSS +
Tu vas revoir le style globale,
SYSTÈME DE COMPTE CROSS-OVER AVEC APSSWORD DANS LE .ENV
FAIRE UN SYSTÈME DE SUBSTAINBILITY.
AVEC UNE BASE DE DONNÉES .
-> Utilise le driver sqlite de bun native.
à garder en db permantente:
- Les chats avec leur ids, leur thémathiqeu
- Les username, displayName, les comptes
- les thèmes des comptes, leur avatar héberger sur un cdn distant (catbox.moe par exemple)
Faire un système de compte No-KYC avec des clées gérer par le compte admin qui as sa clé dans le .env
Modal à intégrer dans le lucide dev, les selcteur de choix,dialog box, bouton, confirm etc doit être handle par un beau frontend qui est hooké par le framework
+84
View File
@@ -0,0 +1,84 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.kisassistant;
in {
options.services.kisassistant = {
enable = lib.mkEnableOption "KisAssistant private chat assistant";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.bun;
description = "KisAssistant package built from this flake (set via nixpkgs overlay or flake packages output).";
};
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 = "e.g. chat.kisakay.com — enables bundled nginx reverse proxy.";
};
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 {
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;
DATA_DIR = "${cfg.dataDir}/data";
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}";
};
script = ''
${lib.optionalString (cfg.passwordFile != null) ''
export APP_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/app-password")"
''}
exec ${cfg.package}/bin/kisassistant
'';
};
services.nginx = lib.mkIf (cfg.domain != null) {
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;";
};
};
};
};
}
+29
View File
@@ -0,0 +1,29 @@
{
"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": {},
"devDependencies": {
"@types/bun": "latest"
}
}
+35
View File
@@ -0,0 +1,35 @@
faire un mocking de ChatGPT juste avec mdp en hosted sur
chat.kisakay.com
Pas d 'api backend, soit utiliser Olama en LLM ou Pupeteer sur un chagpt.com par exemple.
Cahier des charges: faire un site ultra minimlasite en HTMl/CSS/JS pur sans de ressource lourde à charger.
ou bien sert toi d'un framework JS/TS mais choisis le en fonciton des recommendations
un site facile, Desktop Oriented first, avec mdp à la connection + gestion auth via clé.
évite cookies -> utilise une clé one time + rate limite sur auth
Donc faire un backend.
Choix du langage: golang ou rust ou bun.
Doit être facilement hébergable via un NixOS (soit module, ou flakes git)
Le git est un git custom gitea. git.kisakay.com <- remote HTTP
le site doit être simple, gestion thème blanc + noir(automatiquement) only english
le site doit imiter l'interface de chatgpt/ claude
le nom du truc seras :"KisAssistant" (kisa 's assistant)
le backend doit gérer les modèles de manière intéressante (logique orienté classe comme des drivers)
Exemple: driver openai
-> Gesiton du driver via un crawler (telle que pupeteer + scraping backend sur le site.)
Exemple: modèle openai -> backend utilise un browser headless pour use openai pour hooké le prompt
- Driver Ollama avec modèle disponible:
Utilisation par exemple de Ollama définis via le backend; montre les modèles disponible, Utilisation de ceux-si...
- Api OpenAI/Anthropic/Deepseek
Tu peux les implémenters, surtout dans ces priorités:
DeepSeek -> Anthropic -> OpenAI. Tu feras ces drivers à la fin.
autre exemple: Modèle gratuit: Mistral
une des priorités des drivers:
Mistral
priorités des drivers global:
llm Local via ollama -> Api mistral -> driver openai (pupeteer) ->Api (avec leur priorités distinct)
le site web doit gérer le markdown des input/output. (utilise des plugins react par exemple)
Si tu as une question, hésite ABSOLUMENT pas à demander
+115
View File
@@ -0,0 +1,115 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { config } from "./config.ts";
import {
createSession,
deleteSession,
getDb,
getSession,
getUserById,
getUserByUsername,
toPublicUser,
type PublicUser,
} from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser };
function sha256hex(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/** Compare two secrets in constant time (via their hashes so lengths match). */
export function secretsEqual(a: string, bHash: string): boolean {
const aHash = sha256hex(a);
const ab = Buffer.from(aHash, "utf8");
const bb = Buffer.from(bHash, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
export function hashSecret(s: string): string {
return sha256hex(s);
}
/** Fresh random per-account access key (No-KYC: admin hands it to the user). Shown only once. */
export function newAccessKey(): string {
return "ka_" + randomBytes(24).toString("base64url");
}
function checkAdminKey(candidate: string): boolean {
const a = createHash("sha256").update(candidate).digest();
const b = createHash("sha256").update(config.appPassword).digest();
return timingSafeEqual(a, b);
}
/**
* Login with { username, key }.
* - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key.
*/
export function login(username: string, key: string): PublicUser | null {
if (username === "admin") {
if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin");
if (!admin) return null;
return toPublicUser(admin);
}
const u = getUserByUsername(username);
if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u);
}
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt };
}
export function verifyToken(token: string): PublicUser | null {
const s = getSession(sha256hex(token));
if (!s) return null;
const u = getUserById(s.user_id);
if (!u) {
deleteSession(sha256hex(token));
return null;
}
return toPublicUser(u);
}
export function revokeToken(token: string): void {
deleteSession(sha256hex(token));
}
export function extractBearer(req: Request): string | null {
const h = req.headers.get("authorization");
if (!h) return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1]!.trim() : null;
}
// --- sliding-window rate limiter (per IP) for /api/auth/login ---
const attempts = new Map<string, number[]>();
export function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowStart = now - config.rateLimitWindowMs;
const list = (attempts.get(ip) ?? []).filter((t) => t > windowStart);
attempts.set(ip, list);
return list.length >= config.rateLimitMax;
}
export function recordAttempt(ip: string): void {
const list = attempts.get(ip) ?? [];
list.push(Date.now());
attempts.set(ip, list);
}
export function clearAttempts(ip: string): void {
attempts.delete(ip);
}
+54
View File
@@ -0,0 +1,54 @@
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 = {
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),
// 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).
driverDebug: envBool("DRIVER_DEBUG", true),
};
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);
}
}
+317
View File
@@ -0,0 +1,317 @@
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;
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;
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
);
`);
// 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,
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; 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,
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { displayName?: string; avatarUrl?: string; theme?: 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.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 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[];
}
// --- 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; 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",
sharedAt: s.created_at,
};
}
+124
View File
@@ -0,0 +1,124 @@
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
import { DriverDisabledError } from "./types.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
/**
* Base class for OpenAI-compatible HTTP API drivers
* (mistral / 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)}`);
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
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"],
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import { config } from "../config.ts";
/** Concise per-driver debug logging to stdout (toggle with DRIVER_DEBUG). */
export function driverLog(driver: string, msg: string): void {
if (!config.driverDebug) return;
const t = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
console.log(`[${t}][driver:${driver}] ${msg}`);
}
export function fmtBytes(n: number): string {
if (n < 1024) return `${n}B`;
return `${(n / 1024).toFixed(1)}KB`;
}
export function fmtMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
+111
View File
@@ -0,0 +1,111 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
interface OllamaTag {
name: string;
}
/** Local LLM via Ollama. Priority #1. */
export class OllamaDriver implements LLMDriver {
readonly name = "ollama";
readonly enabled: boolean;
constructor(private 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
@@ -0,0 +1,27 @@
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;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { AnthropicDriver, DeepSeekDriver, 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. puppeteer-openai (stub)
* -> 4. deepseek -> 5. anthropic -> 6. openai
*/
const PRIORITY = ["ollama", "mistral", "puppeteer-openai", "deepseek", "anthropic", "openai"];
export class DriverRegistry {
private drivers: Map<string, LLMDriver>;
constructor() {
const all: LLMDriver[] = [
new OllamaDriver(),
new MistralDriver(),
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;
}
/**
* 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
@@ -0,0 +1,42 @@
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";
}
}
+469
View File
@@ -0,0 +1,469 @@
import { serveStatic } from "./static.ts";
import { assertConfig, config } from "./config.ts";
import {
clearAttempts,
extractBearer,
hashSecret,
isRateLimited,
issueToken,
login,
newAccessKey,
recordAttempt,
revokeToken,
verifyToken,
type PublicUser,
} from "./auth.ts";
import {
addMessage,
createConversation,
createUser,
deleteConversation,
deleteShare,
deleteUser,
deleteUserSessions,
ensureShare,
getConversation,
getMessages,
getShareByConv,
getShareByPublic,
getUserById,
getUserByUsername,
listConversations,
listUsers,
toPublicUser,
touchConversation,
updateConversation,
updateUser,
} from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts";
import { DriverRegistry } from "./drivers/registry.ts";
assertConfig();
const registry = new DriverRegistry();
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
try {
const info = server.requestIP?.(req);
if (info?.address) return info.address;
} catch {
// ignore
}
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
function requireAuth(req: Request): PublicUser | null {
const token = extractBearer(req);
if (!token) return null;
return verifyToken(token);
}
function validMessages(v: unknown): v is ChatMessage[] {
if (!Array.isArray(v) || v.length === 0 || v.length > 200) return false;
return v.every(
(m) =>
m &&
typeof m === "object" &&
(m.role === "user" || m.role === "assistant" || m.role === "system") &&
typeof m.content === "string" &&
m.content.length <= 100_000,
);
}
function cleanStr(v: unknown, max: number): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length === 0 || t.length > max) return null;
return t;
}
function validTheme(v: unknown): v is string {
return v === "auto" || v === "light" || v === "dark";
}
function validAvatar(v: unknown): string | null {
if (v === "" || v === undefined || v === null) return "";
if (typeof v !== "string") return null;
const t = v.trim();
if (t.length > 2048) return null;
if (!/^https?:\/\//i.test(t)) return null;
return t;
}
function validUsername(v: unknown): string | null {
if (typeof v !== "string") return null;
const t = v.trim().toLowerCase();
if (!/^[a-z0-9._-]{2,32}$/.test(t) || t === "admin") return null;
return t;
}
async function readJson(req: Request): Promise<{ ok: boolean; body: Record<string, unknown> }> {
try {
return { ok: true, body: (await req.json()) as Record<string, unknown> };
} catch {
return { ok: false, body: {} };
}
}
const server = Bun.serve({
port: config.port,
hostname: config.host,
// SSE chat streams can legitimately go silent for a while (cold model load,
// slow/thinking models). Bun's default 10s idle timeout would kill them
// mid-generation — disable it (0). Nginx in front has its own timeouts.
idleTimeout: 0,
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// --- public: shared chat page data (no auth) ---
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
if (shareMatch && req.method === "GET") {
const s = getShareByPublic(shareMatch[1]!);
if (!s) return json({ error: "share not found" }, 404);
return json({
title: s.conv.title,
topic: s.conv.topic,
model: s.conv.model,
authorName: s.authorName,
sharedAt: s.sharedAt,
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
});
}
// --- public: /wiki redirects to the remote docs wiki (git forge) ---
if ((path === "/wiki" || path === "/wiki/") && req.method === "GET") {
return Response.redirect(config.wikiUrl, 302);
}
// --- auth: login (rate limited) ---
if (path === "/api/auth/login" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
if (!ok || typeof body.username !== "string" || typeof body.key !== "string") {
recordAttempt(ip);
return json({ error: 'expected { username, key }' }, 400);
}
const user = login(body.username.trim().toLowerCase(), body.key);
if (!user) {
recordAttempt(ip);
await Bun.sleep(400);
return json({ error: "invalid credentials" }, 401);
}
clearAttempts(ip);
const { token, expiresAt } = issueToken(user);
return json({ token, expiresAt, user });
}
if (path === "/api/auth/verify" && req.method === "GET") {
const user = requireAuth(req);
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
}
if (path === "/api/auth/logout" && req.method === "POST") {
const token = extractBearer(req);
if (token) revokeToken(token);
return json({ ok: true });
}
// --- everything below requires auth ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401);
const admin = user.isAdmin;
// --- self profile ---
if (path === "/api/me" && req.method === "GET") return json({ user });
if (path === "/api/me" && req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
patch.displayName = d;
}
if (body.avatarUrl !== undefined) {
const a = validAvatar(body.avatarUrl);
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty (e.g. catbox.moe link)" }, 400);
patch.avatarUrl = a;
}
if (body.theme !== undefined) {
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
const updated = updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) });
}
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
if (path === "/api/admin/users" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ users: listUsers().map(toPublicUser) });
}
if (path === "/api/admin/users" && req.method === "POST") {
if (!admin) return json({ error: "forbidden" }, 403);
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const username = validUsername(body.username);
if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
if (getUserByUsername(username)) return json({ error: "username taken" }, 409);
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
const theme = body.theme === undefined ? "auto" : body.theme;
if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400);
const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, keyHash: hashSecret(key) });
// Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201);
}
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate)?$/);
if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!);
if (!target || target.username === "admin") return json({ error: "user not found" }, 404);
if (req.method === "DELETE" && !adminUserMatch[2]) {
deleteUser(target.id);
return json({ ok: true });
}
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
const key = newAccessKey();
updateUser(target.id, { keyHash: hashSecret(key) });
deleteUserSessions(target.id);
return json({ key });
}
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { displayName?: string; avatarUrl?: string; theme?: string } = {};
if (body.displayName !== undefined) {
const d = cleanStr(body.displayName, 60);
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
patch.displayName = d;
}
if (body.avatarUrl !== undefined) {
const a = validAvatar(body.avatarUrl);
if (a === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400);
patch.avatarUrl = a;
}
if (body.theme !== undefined) {
if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400);
patch.theme = body.theme;
}
const updated = updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) });
}
return json({ error: "not found" }, 404);
}
// --- conversations (server-persisted, per account) ---
if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) });
}
if (path === "/api/conversations" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const conv = createConversation(user.id, {
title: typeof body.title === "string" ? body.title : undefined,
topic: typeof body.topic === "string" ? body.topic : undefined,
model: typeof body.model === "string" ? body.model : undefined,
});
return json({ conversation: conv }, 201);
}
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
if (convMatch) {
const conv = getConversation(convMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
const isShare = convMatch[3] === "share";
if (isShare) {
if (req.method === "POST") {
const publicId = ensureShare(conv.id);
return json({ publicId, url: `/share/${publicId}` }, 201);
}
if (req.method === "GET") {
const publicId = getShareByConv(conv.id);
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
}
if (req.method === "DELETE") {
deleteShare(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
}
if (req.method === "GET") {
return json({ conversation: conv, messages: getMessages(conv.id) });
}
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const patch: { title?: string; topic?: string; model?: string } = {};
if (body.title !== undefined) {
if (typeof body.title !== "string" || body.title.length > 120) return json({ error: "title too long" }, 400);
patch.title = body.title.trim();
}
if (body.topic !== undefined) {
if (typeof body.topic !== "string" || body.topic.length > 120) return json({ error: "topic too long" }, 400);
patch.topic = body.topic.trim();
}
if (body.model !== undefined) {
if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400);
patch.model = body.model;
}
return json({ conversation: updateConversation(conv.id, patch) });
}
if (req.method === "DELETE") {
deleteConversation(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
}
if (path === "/api/models" && req.method === "GET") {
const models = await registry.listAllModels();
return json({ models });
}
if (path === "/api/chat" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
if (typeof body.model !== "string" || !validMessages(body.messages)) {
return json({ error: 'expected { model: "driver:model", messages: [{role, content}] }' }, 400);
}
let driver, model: string;
try {
({ driver, model } = registry.splitId(body.model));
} catch (e) {
return json({ error: (e as Error).message }, 400);
}
const messages = body.messages as ChatMessage[];
// Optional persistence into a server-side conversation (must belong to the user).
let conv = null;
if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
conv = getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
// Persist the new user message (dedupe against last stored one).
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const stored = getMessages(conv.id);
const lastStored = stored[stored.length - 1];
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
addMessage(conv.id, "user", lastUser.content);
if (conv.title === "New chat") {
updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
}
}
}
const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream";
const persistReply = (text: string) => {
if (conv && text) {
addMessage(conv.id, "assistant", text);
touchConversation(conv.id, body.model as string);
}
};
if (!wantStream) {
try {
const text = await driver.chat(messages, { model, signal: req.signal });
persistReply(text);
return json({ model: body.model, message: { role: "assistant", content: text } });
} catch (e) {
return json({ error: (e as Error).message }, 502);
}
}
// True streaming (ChatGPT-style): driver tokens are forwarded as SSE
// events the moment they arrive. Client disconnects are propagated
// upstream so the model stops generating, and we never write to a
// closed controller (the old "[interrupted: Controller is already
// closed]" crash).
const abortUpstream = new AbortController();
const onClientAbort = () => abortUpstream.abort();
req.signal.addEventListener("abort", onClientAbort);
let clientGone = false;
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const safeSend = (event: string, data: string): boolean => {
if (clientGone || req.signal.aborted) return false;
try {
controller.enqueue(enc.encode(`event: ${event}\ndata: ${data}\n\n`));
return true;
} catch {
// Controller already closed (client gone) — stop pulling upstream.
clientGone = true;
abortUpstream.abort();
return false;
}
};
let full = "";
try {
for await (const token of driver.chatStream(messages, { model, signal: abortUpstream.signal })) {
if (req.signal.aborted) break;
full += token;
if (!safeSend("token", JSON.stringify({ token }))) break;
}
persistReply(full);
safeSend("done", "{}");
} catch (e) {
// Genuine driver error: keep the partial text as-is (no internal
// markers persisted) and notify the client if still connected.
if (full) persistReply(full);
if (!req.signal.aborted && !clientGone) {
safeSend("error", JSON.stringify({ error: (e as Error).message }));
}
} finally {
req.signal.removeEventListener("abort", onClientAbort);
abortUpstream.abort();
if (!clientGone) {
try {
controller.close();
} catch {
// already closed — fine
}
}
}
},
cancel() {
clientGone = true;
abortUpstream.abort();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // nginx: never buffer SSE
},
});
}
return json({ error: "not found" }, 404);
}
// --- static frontend (SPA fallback covers /share/:id) ---
return serveStatic(req, url);
},
});
console.log(`KisAssistant listening on http://${config.host}:${config.port}`);
console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`);
void server;
+38
View File
@@ -0,0 +1,38 @@
import { join } from "node:path";
const PUBLIC_DIR = join(import.meta.dir, "..", "public");
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
};
export async function serveStatic(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("not found", { status: 404 });
}
let path = decodeURIComponent(url.pathname);
if (path === "/") path = "/index.html";
// block traversal
if (path.includes("..") || path.includes("\0")) return new Response("not found", { status: 404 });
const file = Bun.file(join(PUBLIC_DIR, path));
if (!(await file.exists())) {
// SPA fallback for non-API routes
if (!path.startsWith("/api/")) {
return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
}
return new Response("not found", { status: 404 });
}
const ext = path.slice(path.lastIndexOf("."));
return new Response(file, {
headers: {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": path === "/index.html" ? "no-cache" : "public, max-age=3600",
},
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"types": ["bun"]
},
"include": ["src"]
}