KisAssistant 0.2.0: React UI, SQLite accounts, shares, drivers, NixOS module
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# --- 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
|
||||
|
||||
# --- 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
@@ -0,0 +1,10 @@
|
||||
data/
|
||||
node_modules/
|
||||
dist/
|
||||
public/
|
||||
.env
|
||||
*.log
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Architecture (backend)
|
||||
|
||||
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
|
||||
frontend (same origin, no CORS). Persistence is SQLite via Bun's native
|
||||
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode).
|
||||
|
||||
```
|
||||
browser ──► Bun.serve (src/index.ts)
|
||||
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db
|
||||
│ └─────► DriverRegistry ──► ollama / APIs
|
||||
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id)
|
||||
```
|
||||
|
||||
## Request flow: chat
|
||||
|
||||
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
|
||||
|
||||
1. Bearer token → `verifyToken` (sha256 lookup in `sessions`, expiry check).
|
||||
2. `DriverRegistry.splitId` routes to the owning driver (unknown/disabled → 400).
|
||||
3. If `conversationId` is set: conversation must belong to the caller, the new
|
||||
user message is stored (deduped against the last stored message), new chats
|
||||
are auto-titled from it.
|
||||
4. Non-streaming → `driver.chat()` → JSON. Streaming → `driver.chatStream()`
|
||||
→ SSE events `token` / `done` / `error`. The full reply is persisted after a
|
||||
successful stream (partial text + `[interrupted]` marker on failure).
|
||||
|
||||
## Database schema
|
||||
|
||||
Created idempotently at boot in `getDb()` (no migration framework; keep it so).
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `users` | `id, username UNIQUE, display_name, avatar_url, theme, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
|
||||
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
|
||||
| `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. |
|
||||
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
|
||||
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
|
||||
|
||||
User deletion cascades manually in `deleteUser()` (messages → shares →
|
||||
conversations → sessions → user) inside a transaction.
|
||||
|
||||
## Auth
|
||||
|
||||
- `POST /api/auth/login { username, key }`, sliding-window rate limit per IP
|
||||
(`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN`, default 5 per 10 min) + 400 ms
|
||||
delay on failure. Success clears the IP counter and issues a fresh token.
|
||||
- `admin` is authenticated against `APP_PASSWORD` (timing-safe); users against
|
||||
their `ka_…` access key (timing-safe against the stored hash).
|
||||
- Keys are generated with `crypto.randomBytes` (`ka_` + 24 bytes base64url) and
|
||||
**returned once** at creation/regeneration. Regeneration also wipes the
|
||||
account's sessions.
|
||||
|
||||
## Drivers
|
||||
|
||||
`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||
`DriverRegistry` with global priority
|
||||
`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`.
|
||||
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||
|
||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||
`POST /api/chat` with NDJSON streaming.
|
||||
- `ApiDriverBase`: shared OpenAI-compatible `/models` + `/chat/completions`
|
||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
||||
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
|
||||
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
||||
- `PuppeteerOpenAIDriver`: intentional stub, always disabled (headless scraping
|
||||
of chatgpt.com is fragile and against ToS).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Frontend
|
||||
|
||||
React 18 + Vite + Tailwind 3 + lucide-react + react-markdown/remark-gfm, Inter
|
||||
Variable self-hosted (`@fontsource-variable/inter` — no external requests).
|
||||
Built with `bun run build:frontend` into `public/` (Vite `emptyOutDir`; that
|
||||
directory is a build artifact, never hand-edit, not committed).
|
||||
|
||||
## Routing
|
||||
|
||||
No router library — `App.tsx` matches `window.location.pathname` once (module
|
||||
level, constant for the page lifetime): `/share/:publicId` renders the public
|
||||
read-only `SharePage` (no auth), everything else renders the authenticated app.
|
||||
|
||||
## State & data flow
|
||||
|
||||
- `App.tsx` owns: `user`, `models`/`model`, `convs`, `activeId`, `messages`,
|
||||
`streaming`, modal states. All server state is re-fetched (no client cache
|
||||
library); conversations themselves live server-side in SQLite.
|
||||
- `lib/api.ts` — typed `fetch` wrapper: Bearer header from `localStorage`,
|
||||
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
||||
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
||||
- Sending: ensure a server conversation exists (created lazily on first send),
|
||||
optimistic user message, stream tokens into `streaming`, then append the full
|
||||
assistant message and refresh the list (server auto-titles new chats).
|
||||
|
||||
## Components (`components/`)
|
||||
|
||||
- `ui.tsx` — design system. `Button` (primary/secondary/ghost/danger),
|
||||
`IconButton`, `Input`, `Field`, `Modal`, `ConfirmDialog`, `ContextMenu`,
|
||||
`Avatar` (remote URL or gradient initials), `CopyButton`, `Spinner` and
|
||||
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
|
||||
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
|
||||
**Never use a native `<select>`**; use `Picker` for model and theme choices.
|
||||
- `Login.tsx` — username + access-key card.
|
||||
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
|
||||
(right-click or `···`: Rename / Set topic / Share publicly / Delete), user
|
||||
chip (opens settings), Accounts button (admin), logout.
|
||||
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
|
||||
share button), message list (`Markdown` for assistant, emerald bubble for
|
||||
user), rounded composer (Enter to send, Shift+Enter for newline).
|
||||
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
||||
copy, unshare), `SettingsModal` (display name, avatar URL, theme).
|
||||
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
|
||||
regenerate (revokes sessions), edit, delete with confirm.
|
||||
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated.
|
||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
|
||||
a copy button.
|
||||
|
||||
## Theming
|
||||
|
||||
Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark`
|
||||
class from the account theme (`auto` follows `prefers-color-scheme` with a
|
||||
live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl`
|
||||
everywhere; every surface needs a `dark:` variant. UI text is English only.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Operations
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Full list:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
|
||||
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
|
||||
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
|
||||
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
|
||||
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
|
||||
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
|
||||
| `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # backend watch mode (restarts on edit), :3000
|
||||
bun run dev:frontend # Vite HMR + /api proxy, :5173 (use this for UI work)
|
||||
bun run dev:all # both at once
|
||||
bun run build:frontend # production build into public/
|
||||
```
|
||||
|
||||
Smoke tests: start the backend on a **free non-3000 port** with a throwaway
|
||||
`DATA_DIR` (port 3000 is the owner's dev server — do not touch it), then drive
|
||||
the API with curl/python: login → models → conversations → chat → share →
|
||||
unshare. See `AGENTS.md`.
|
||||
|
||||
## Managing accounts
|
||||
|
||||
1. Log in as `admin` with the `APP_PASSWORD` key.
|
||||
2. Open **Accounts** → *New account* → username (+ display name).
|
||||
3. Copy the one-time `ka_…` key and hand it to the owner (chat, QR code, …).
|
||||
4. Users set their display name, avatar URL (upload e.g. to `catbox.moe`, paste
|
||||
the direct link) and theme in the profile settings.
|
||||
5. Rotate a compromised key with the regenerate button (old sessions die);
|
||||
delete removes the account with all its chats and shares.
|
||||
|
||||
## NixOS deployment (`chat.kisakay.com`)
|
||||
|
||||
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend)
|
||||
and `nixosModules.default`. The frontend is compiled offline with
|
||||
`buildNpmPackage` from `frontend/package-lock.json` — commit that file, and on
|
||||
a hash mismatch paste the hash Nix reports into `npmDepsHash`.
|
||||
|
||||
```nix
|
||||
imports = [ kisassistant.nixosModules.default ];
|
||||
services.kisassistant = {
|
||||
enable = true;
|
||||
package = kisassistant.packages.${pkgs.system}.default;
|
||||
domain = "chat.kisakay.com"; # nginx + ACME
|
||||
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
|
||||
ollamaHost = "http://10.66.66.4:11434";
|
||||
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
|
||||
# secrets for API drivers: pass via environment files / extraEnv, never the store
|
||||
};
|
||||
```
|
||||
|
||||
Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never
|
||||
baked into the Nix store. The service stores SQLite under
|
||||
`/var/lib/kisassistant/data`; nginx terminates TLS and proxies to
|
||||
`127.0.0.1:3000`.
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "kisassistant-frontend",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
|
||||
|
||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/[email protected]", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/[email protected]", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||
|
||||
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
|
||||
|
||||
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="],
|
||||
|
||||
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
|
||||
|
||||
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
|
||||
|
||||
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="],
|
||||
|
||||
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["[email protected]", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-url-attributes": ["[email protected]", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"inline-style-parser": ["[email protected]", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["[email protected]", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
|
||||
|
||||
"is-decimal": ["[email protected]", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["[email protected]", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"longest-streak": ["[email protected]", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ=="],
|
||||
|
||||
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-gfm": ["[email protected]", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["[email protected]", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["[email protected]", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["[email protected]", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["[email protected]", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["[email protected]", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["[email protected]", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["[email protected]", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="],
|
||||
|
||||
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"parse-entities": ["[email protected]", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-markdown": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
|
||||
|
||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"read-cache": ["[email protected]", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"remark-gfm": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
|
||||
|
||||
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"style-to-js": ["[email protected]", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["[email protected]", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["[email protected]", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"unified": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3833
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "kisassistant-frontend",
|
||||
"version": "0.2.0",
|
||||
"description": "KisAssistant React frontend (Vite + Tailwind + lucide-react)",
|
||||
"author": "kisakay",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://git.kisakay.com/k/chat",
|
||||
"directory": "frontend"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, Sparkles, User as UserIcon } from "lucide-react";
|
||||
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||
import { Avatar, IconButton, 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 : undefined} 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">
|
||||
<Sparkles size={16} className="text-emerald-600 dark:text-emerald-400" />
|
||||
{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">
|
||||
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-3xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
|
||||
<Sparkles size={24} />
|
||||
</span>
|
||||
<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" 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">No cookies — a Bearer key is stored in this browser only.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Sparkles, User } from "lucide-react";
|
||||
import { api, setToken } from "../lib/api.ts";
|
||||
import type { User as UserType } from "../lib/types.ts";
|
||||
import { Button, Field, Input, Spinner } from "./ui.tsx";
|
||||
|
||||
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 bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-50 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/30">
|
||||
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200/70 bg-white/90 p-8 shadow-2xl backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
|
||||
<Sparkles size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">KisAssistant</h1>
|
||||
<p className="text-sm opacity-60">Private assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Field label="Username">
|
||||
<div className="relative">
|
||||
<User size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
|
||||
<Input className="pl-10" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Access key" hint="Admin key lives in the server .env; user keys are issued by the admin.">
|
||||
<div className="relative">
|
||||
<KeyRound size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
{error && <p className="rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
|
||||
<Button type="submit" size="lg" className="w-full" disabled={busy}>
|
||||
{busy ? <Spinner /> : "Unlock"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CopyButton } from "./ui.tsx";
|
||||
|
||||
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{lang && (
|
||||
<span className="absolute left-4 top-2.5 text-[11px] font-medium uppercase tracking-wide text-stone-400">{lang}</span>
|
||||
)}
|
||||
<div className="absolute right-2.5 top-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className={lang ? "pt-9" : ""}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="md space-y-3 text-[15px] leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre({ children }) {
|
||||
// react-markdown nests <code> inside <pre>; render our block instead.
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
|
||||
const className = props?.className || "";
|
||||
const lang = className.replace("language-", "");
|
||||
const code = String(props?.children ?? "").replace(/\n$/, "");
|
||||
return <CodeBlock code={code} lang={lang} />;
|
||||
},
|
||||
code({ children }) {
|
||||
return <code>{children}</code>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bot, Globe, Sparkles, User as UserIcon } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { SharedChat } from "../lib/types.ts";
|
||||
import { Avatar } 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">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white">
|
||||
<Sparkles size={17} />
|
||||
</span>
|
||||
<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"} 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
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" | "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 === "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(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none transition",
|
||||
"placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20",
|
||||
"dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500",
|
||||
)}
|
||||
{...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 all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
|
||||
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [open ]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<button
|
||||
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 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"absolute top-full z-50 mt-2 max-h-72 min-w-52 max-w-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",
|
||||
align === "right" ? "right-0" : "left-0",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@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; }
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{ 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}";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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),
|
||||
};
|
||||
|
||||
export function assertConfig(): void {
|
||||
if (!config.appPassword) {
|
||||
console.error("FATAL: APP_PASSWORD is not set. Copy .env.example to .env and set it.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||
import { DriverDisabledError } from "./types.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) {
|
||||
return data.data.map((m) => ({ id: `${this.name}:${m.id}`, name: m.id, driver: this.name, label: m.id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to 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 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(() => "");
|
||||
throw new Error(`${this.name} chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
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]") return;
|
||||
try {
|
||||
const obj = JSON.parse(payload) as { choices?: { delta?: { content?: string } }[] };
|
||||
const token = obj.choices?.[0]?.delta?.content ?? "";
|
||||
if (token) {
|
||||
opts.onToken?.(token);
|
||||
yield token;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { config } from "../config.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 res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) throw new Error(`ollama /api/tags failed: ${res.status}`);
|
||||
const data = (await res.json()) as { models?: OllamaTag[] };
|
||||
const models = data.models ?? [];
|
||||
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 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(() => "");
|
||||
throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
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) {
|
||||
opts.onToken?.(token);
|
||||
yield token;
|
||||
}
|
||||
if (obj.done) return;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) continue; // partial line, ignore
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
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,
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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 });
|
||||
persistReply(text);
|
||||
return json({ model: body.model, message: { role: "assistant", content: text } });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (event: string, data: string) =>
|
||||
controller.enqueue(enc.encode(`event: ${event}\ndata: ${data}\n\n`));
|
||||
let full = "";
|
||||
try {
|
||||
for await (const token of driver.chatStream(messages, { model })) {
|
||||
full += token;
|
||||
send("token", JSON.stringify({ token }));
|
||||
}
|
||||
persistReply(full);
|
||||
send("done", "{}");
|
||||
} catch (e) {
|
||||
if (full) persistReply(full + "\n\n[interrupted: " + (e as Error).message + "]");
|
||||
send("error", JSON.stringify({ error: (e as Error).message }));
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
const PUBLIC_DIR = join(import.meta.dir, "..", "public");
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
export async function serveStatic(req: Request, url: URL): Promise<Response> {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
let path = decodeURIComponent(url.pathname);
|
||||
if (path === "/") path = "/index.html";
|
||||
// block traversal
|
||||
if (path.includes("..") || path.includes("\0")) return new Response("not found", { status: 404 });
|
||||
const file = Bun.file(join(PUBLIC_DIR, path));
|
||||
if (!(await file.exists())) {
|
||||
// SPA fallback for non-API routes
|
||||
if (!path.startsWith("/api/")) {
|
||||
return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
const ext = path.slice(path.lastIndexOf("."));
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": path === "/index.html" ? "no-cache" : "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user